Skip to content

Commit 1b54e15

Browse files
Indent/Unindent Selected Lines (CodeEditApp#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. * CodeEditApp#123) --> <!--- If this PR resolves the issue please specify (e.g. * closes CodeEditApp#123) --> <!--- If this PR addresses multiple issues, these issues must be related to one other --> * closes CodeEditApp#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 <35942988+thecoolwinter@users.noreply.github.com>
1 parent d73edc6 commit 1b54e15

File tree

4 files changed

+286
-7
lines changed

4 files changed

+286
-7
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//
2+
// TextViewController+IndentLines.swift
3+
//
4+
//
5+
// Created by Ludwig, Tom on 11.09.24.
6+
//
7+
8+
import CodeEditTextView
9+
import AppKit
10+
11+
extension TextViewController {
12+
/// Handles indentation and unindentation
13+
///
14+
/// Handles the indentation of lines in the text view based on the current indentation option.
15+
///
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+
///
20+
/// - Parameter inwards: A Boolean flag indicating whether to outdent (default is `false`).
21+
public func handleIndent(inwards: Bool = false) {
22+
let indentationChars: String = indentOption.stringValue
23+
guard !cursorPositions.isEmpty else { return }
24+
25+
textView.undoManager?.beginUndoGrouping()
26+
for cursorPosition in self.cursorPositions.reversed() {
27+
// 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+
}
37+
}
38+
textView.undoManager?.endUndoGrouping()
39+
}
40+
41+
/// This method is used to handle tabs appropriately when multiple lines are selected,
42+
/// allowing normal use of tabs.
43+
///
44+
/// - Returns: A Boolean value indicating whether multiple lines are highlighted.
45+
func multipleLinesHighlighted() -> Bool {
46+
for cursorPosition in self.cursorPositions {
47+
if let startLineInfo = textView.layoutManager.textLineForOffset(cursorPosition.range.lowerBound),
48+
let endLineInfo = textView.layoutManager.textLineForOffset(cursorPosition.range.upperBound),
49+
startLineInfo.index != endLineInfo.index {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
56+
private func getHighlightedLines(for range: NSRange) -> ClosedRange<Int>? {
57+
guard let startLineInfo = textView.layoutManager.textLineForOffset(range.lowerBound) else {
58+
return nil
59+
}
60+
61+
guard let endLineInfo = textView.layoutManager.textLineForOffset(range.upperBound),
62+
endLineInfo.index != startLineInfo.index else {
63+
return startLineInfo.index...startLineInfo.index
64+
}
65+
66+
return startLineInfo.index...endLineInfo.index
67+
}
68+
69+
private func adjustIndentation(lineIndex: Int, indentationChars: String, inwards: Bool) {
70+
guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return }
71+
72+
if inwards {
73+
if indentOption != .tab {
74+
removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count)
75+
} else {
76+
removeLeadingTab(lineInfo: lineInfo)
77+
}
78+
} else {
79+
addIndentation(lineInfo: lineInfo, indentationChars: indentationChars)
80+
}
81+
}
82+
83+
private func addIndentation(
84+
lineInfo: TextLineStorage<TextLine>.TextLinePosition,
85+
indentationChars: String
86+
) {
87+
textView.replaceCharacters(
88+
in: NSRange(location: lineInfo.range.lowerBound, length: 0),
89+
with: indentationChars
90+
)
91+
}
92+
93+
private func removeLeadingSpaces(
94+
lineInfo: TextLineStorage<TextLine>.TextLinePosition,
95+
spaceCount: Int
96+
) {
97+
guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else { return }
98+
99+
let removeSpacesCount = countLeadingSpacesUpTo(line: lineContent, maxCount: spaceCount)
100+
101+
guard removeSpacesCount > 0 else { return }
102+
103+
textView.replaceCharacters(
104+
in: NSRange(location: lineInfo.range.lowerBound, length: removeSpacesCount),
105+
with: ""
106+
)
107+
}
108+
109+
private func removeLeadingTab(lineInfo: TextLineStorage<TextLine>.TextLinePosition) {
110+
guard let lineContent = textView.textStorage.substring(from: lineInfo.range) else {
111+
return
112+
}
113+
114+
if lineContent.first == "\t" {
115+
textView.replaceCharacters(
116+
in: NSRange(location: lineInfo.range.lowerBound, length: 1),
117+
with: ""
118+
)
119+
}
120+
}
121+
122+
func countLeadingSpacesUpTo(line: String, maxCount: Int) -> Int {
123+
var count = 0
124+
for char in line {
125+
if char == " " {
126+
count += 1
127+
} else {
128+
break // Stop as soon as a non-space character is encountered
129+
}
130+
// Stop early if we've counted the max number of spaces
131+
if count == maxCount {
132+
break
133+
}
134+
}
135+
136+
return count
137+
}
138+
}

Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,50 @@ extension TextViewController {
115115
}
116116
self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
117117
guard self?.view.window?.firstResponder == self?.textView else { return event }
118-
let commandKey = NSEvent.ModifierFlags.command.rawValue
118+
119+
let tabKey: UInt16 = 0x30
119120
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
120-
if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" {
121-
self?.handleCommandSlash()
122-
return nil
121+
122+
if event.keyCode == tabKey {
123+
return self?.handleTab(event: event, modifierFalgs: modifierFlags)
123124
} else {
124-
return event
125+
return self?.handleCommand(event: event, modifierFlags: modifierFlags)
125126
}
126127
}
127128
}
129+
func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
130+
let commandKey = NSEvent.ModifierFlags.command.rawValue
131+
132+
switch (modifierFlags, event.charactersIgnoringModifiers) {
133+
case (commandKey, "/"):
134+
handleCommandSlash()
135+
return nil
136+
case (commandKey, "["):
137+
handleIndent(inwards: true)
138+
return nil
139+
case (commandKey, "]"):
140+
handleIndent()
141+
return nil
142+
case (_, _):
143+
return event
144+
}
145+
}
146+
147+
/// Handles the tab key event.
148+
/// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines
149+
/// are highlighted and handles indenting accordingly.
150+
///
151+
/// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method.
152+
func handleTab(event: NSEvent, modifierFalgs: UInt) -> NSEvent? {
153+
let shiftKey = NSEvent.ModifierFlags.shift.rawValue
154+
155+
if modifierFalgs == shiftKey {
156+
handleIndent(inwards: true)
157+
} else {
158+
// Only allow tab to work if multiple lines are selected
159+
guard multipleLinesHighlighted() else { return event }
160+
handleIndent()
161+
}
162+
return nil
163+
}
128164
}

Sources/CodeEditSourceEditor/Controller/TextViewController+ToggleComment.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ extension TextViewController {
3838
/// - range: The range of text to process.
3939
/// - commentCache: A cache object to store comment-related data, such as line information,
4040
/// shift factors, and content.
41-
func populateCommentCache(for range: NSRange, using commentCache: inout CommentCache) {
41+
private func populateCommentCache(for range: NSRange, using commentCache: inout CommentCache) {
4242
// Determine the appropriate comment characters based on the language settings.
4343
if language.lineCommentString.isEmpty {
4444
commentCache.startCommentChars = language.rangeCommentStrings.0
@@ -126,7 +126,7 @@ extension TextViewController {
126126
/// - lineCount: The number of intermediate lines between the start and end comments.
127127
///
128128
/// - Returns: The computed shift range factor as an `Int`.
129-
func calculateShiftRangeFactor(startCount: Int, endCount: Int?, lineCount: Int) -> Int {
129+
private func calculateShiftRangeFactor(startCount: Int, endCount: Int?, lineCount: Int) -> Int {
130130
let effectiveEndCount = endCount ?? 0
131131
return (startCount + effectiveEndCount) * (lineCount + 1)
132132
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//
2+
// TextViewController+IndentTests.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Ludwig, Tom on 08.10.24.
6+
//
7+
8+
import XCTest
9+
@testable import CodeEditSourceEditor
10+
11+
final class TextViewControllerIndentTests: XCTestCase {
12+
var controller: TextViewController!
13+
14+
override func setUpWithError() throws {
15+
controller = Mock.textViewController(theme: Mock.theme())
16+
17+
controller.loadView()
18+
}
19+
20+
func testHandleIndentWithSpacesInwards() {
21+
controller.setText(" This is a test string")
22+
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))]
23+
controller.cursorPositions = cursorPositions
24+
controller.handleIndent(inwards: true)
25+
26+
XCTAssertEqual(controller.string, "This is a test string")
27+
28+
// Normally, 4 spaces are used for indentation; however, now we only insert 2 leading spaces.
29+
// The outcome should be the same, though.
30+
controller.setText(" This is a test string")
31+
controller.cursorPositions = cursorPositions
32+
controller.handleIndent(inwards: true)
33+
34+
XCTAssertEqual(controller.string, "This is a test string")
35+
}
36+
37+
func testHandleIndentWithSpacesOutwards() {
38+
controller.setText("This is a test string")
39+
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))]
40+
controller.cursorPositions = cursorPositions
41+
42+
controller.handleIndent(inwards: false)
43+
44+
XCTAssertEqual(controller.string, " This is a test string")
45+
}
46+
47+
func testHandleIndentWithTabsInwards() {
48+
controller.setText("\tThis is a test string")
49+
controller.indentOption = .tab
50+
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))]
51+
controller.cursorPositions = cursorPositions
52+
53+
controller.handleIndent(inwards: true)
54+
55+
XCTAssertEqual(controller.string, "This is a test string")
56+
}
57+
58+
func testHandleIndentWithTabsOutwards() {
59+
controller.setText("This is a test string")
60+
controller.indentOption = .tab
61+
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))]
62+
controller.cursorPositions = cursorPositions
63+
64+
controller.handleIndent()
65+
66+
// Normally, we expect nothing to happen because only one line is selected.
67+
// However, this logic is not handled inside `handleIndent`.
68+
XCTAssertEqual(controller.string, "\tThis is a test string")
69+
}
70+
71+
func testHandleIndentMultiLine() {
72+
controller.indentOption = .tab
73+
controller.setText("This is a test string\nWith multiple lines\nAnd some indentation")
74+
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 5))]
75+
controller.cursorPositions = cursorPositions
76+
77+
controller.handleIndent()
78+
let expectedString = "\tThis is a test string\nWith multiple lines\nAnd some indentation"
79+
XCTAssertEqual(controller.string, expectedString)
80+
}
81+
82+
func testHandleInwardIndentMultiLine() {
83+
controller.indentOption = .tab
84+
controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation")
85+
let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))]
86+
controller.cursorPositions = cursorPositions
87+
88+
controller.handleIndent(inwards: true)
89+
let expectedString = "This is a test string\nWith multiple lines\nAnd some indentation"
90+
XCTAssertEqual(controller.string, expectedString)
91+
}
92+
93+
func testMultipleLinesHighlighted() {
94+
controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation")
95+
var cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))]
96+
controller.cursorPositions = cursorPositions
97+
98+
XCTAssert(controller.multipleLinesHighlighted())
99+
100+
cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 5))]
101+
controller.cursorPositions = cursorPositions
102+
103+
XCTAssertFalse(controller.multipleLinesHighlighted())
104+
}
105+
}

0 commit comments

Comments
 (0)