Skip to content

Commit

Permalink
Indent/Unindent Selected Lines (#266)
Browse files Browse the repository at this point in the history
<!--- 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]>
tom-ludwig and thecoolwinter authored Oct 12, 2024
1 parent d73edc6 commit 1b54e15
Showing 4 changed files with 286 additions and 7 deletions.
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
}
}
Original file line number Diff line number Diff line change
@@ -115,14 +115,50 @@ extension TextViewController {
}
self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard self?.view.window?.firstResponder == self?.textView else { return event }
let commandKey = NSEvent.ModifierFlags.command.rawValue

let tabKey: UInt16 = 0x30
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" {
self?.handleCommandSlash()
return nil

if event.keyCode == tabKey {
return self?.handleTab(event: event, modifierFalgs: modifierFlags)
} else {
return event
return self?.handleCommand(event: event, modifierFlags: modifierFlags)
}
}
}
func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
let commandKey = NSEvent.ModifierFlags.command.rawValue

switch (modifierFlags, event.charactersIgnoringModifiers) {
case (commandKey, "/"):
handleCommandSlash()
return nil
case (commandKey, "["):
handleIndent(inwards: true)
return nil
case (commandKey, "]"):
handleIndent()
return nil
case (_, _):
return event
}
}

/// Handles the tab key event.
/// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines
/// are highlighted and handles indenting accordingly.
///
/// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method.
func handleTab(event: NSEvent, modifierFalgs: UInt) -> NSEvent? {
let shiftKey = NSEvent.ModifierFlags.shift.rawValue

if modifierFalgs == shiftKey {
handleIndent(inwards: true)
} else {
// Only allow tab to work if multiple lines are selected
guard multipleLinesHighlighted() else { return event }
handleIndent()
}
return nil
}
}
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@ extension TextViewController {
/// - range: The range of text to process.
/// - commentCache: A cache object to store comment-related data, such as line information,
/// shift factors, and content.
func populateCommentCache(for range: NSRange, using commentCache: inout CommentCache) {
private func populateCommentCache(for range: NSRange, using commentCache: inout CommentCache) {
// Determine the appropriate comment characters based on the language settings.
if language.lineCommentString.isEmpty {
commentCache.startCommentChars = language.rangeCommentStrings.0
@@ -126,7 +126,7 @@ extension TextViewController {
/// - lineCount: The number of intermediate lines between the start and end comments.
///
/// - Returns: The computed shift range factor as an `Int`.
func calculateShiftRangeFactor(startCount: Int, endCount: Int?, lineCount: Int) -> Int {
private func calculateShiftRangeFactor(startCount: Int, endCount: Int?, lineCount: Int) -> Int {
let effectiveEndCount = endCount ?? 0
return (startCount + effectiveEndCount) * (lineCount + 1)
}
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())
}
}

0 comments on commit 1b54e15

Please sign in to comment.