Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Indent/Unindent Selected Lines #266

Merged
merged 10 commits into from
Oct 12, 2024
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
Expand Up @@ -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
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
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())
}
}
Loading