diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 9a80e4bad..aaff57aaf 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -18,7 +18,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - language: The language for syntax highlighting /// - theme: The theme for syntax highlighting /// - font: The default font - /// - tabWidth: The tab width + /// - tabWidth: The visual tab width in number of spaces + /// - indentOption: The behavior to use when the tab key is pressed. Defaults to 4 spaces. /// - lineHeight: The line height multiplier (e.g. `1.2`) /// - wrapLines: Whether lines wrap to the width of the editor /// - editorOverscroll: The percentage for overscroll, between 0-1 (default: `0.0`) @@ -33,6 +34,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { theme: Binding, font: Binding, tabWidth: Binding, + indentOption: Binding = .constant(.spaces(count: 4)), lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), @@ -48,6 +50,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.useThemeBackground = useThemeBackground self._font = font self._tabWidth = tabWidth + self._indentOption = indentOption self._lineHeight = lineHeight self._wrapLines = wrapLines self._editorOverscroll = editorOverscroll @@ -62,6 +65,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { @Binding private var theme: EditorTheme @Binding private var font: NSFont @Binding private var tabWidth: Int + @Binding private var indentOption: IndentOption @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @Binding private var editorOverscroll: Double @@ -80,6 +84,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { font: font, theme: theme, tabWidth: tabWidth, + indentOption: indentOption, wrapLines: wrapLines, cursorPosition: $cursorPosition, editorOverscroll: editorOverscroll, @@ -94,20 +99,25 @@ public struct CodeEditTextView: NSViewControllerRepresentable { public func updateNSViewController(_ controller: NSViewControllerType, context: Context) { controller.font = font - controller.tabWidth = tabWidth controller.wrapLines = wrapLines controller.useThemeBackground = useThemeBackground controller.lineHeightMultiple = lineHeight controller.editorOverscroll = editorOverscroll controller.contentInsets = contentInsets - // Updating the language and theme needlessly can cause highlights to be re-calculated. + // Updating the language, theme, tab width and indent option needlessly can cause highlights to be re-calculated if controller.language.id != language.id { controller.language = language } if controller.theme != theme { controller.theme = theme } + if controller.indentOption != indentOption { + controller.indentOption = indentOption + } + if controller.tabWidth != tabWidth { + controller.tabWidth = tabWidth + } controller.reloadUI() return diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index f2c80935a..2132b4055 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -37,8 +37,20 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt /// Whether the code editor should use the theme background color or be transparent public var useThemeBackground: Bool - /// The number of spaces to use for a `tab '\t'` character - public var tabWidth: Int + /// The visual width of tab characters in the text view measured in number of spaces. + public var tabWidth: Int { + didSet { + paragraphStyle = generateParagraphStyle() + reloadUI() + } + } + + /// The behavior to use when the tab key is pressed. + public var indentOption: IndentOption { + didSet { + setUpTextFormation() + } + } /// A multiplier for setting the line height. Defaults to `1.0` public var lineHeightMultiple: Double = 1.0 @@ -68,9 +80,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt internal var highlighter: Highlighter? - /// Internal variable for tracking whether or not the textView has the correct standard attributes. - private var hasSetStandardAttributes: Bool = false - /// The provided highlight provider. private var highlightProvider: HighlightProviding? @@ -82,6 +91,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt font: NSFont, theme: EditorTheme, tabWidth: Int, + indentOption: IndentOption, wrapLines: Bool, cursorPosition: Binding<(Int, Int)>, editorOverscroll: Double, @@ -95,6 +105,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.font = font self.theme = theme self.tabWidth = tabWidth + self.indentOption = indentOption self.wrapLines = wrapLines self.cursorPosition = cursorPosition self.editorOverscroll = editorOverscroll @@ -142,6 +153,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt scrollView.verticalRulerView = rulerView scrollView.rulersVisible = true + textView.typingAttributes = attributesFor(nil) textView.defaultParagraphStyle = self.paragraphStyle textView.font = self.font textView.textColor = theme.text @@ -214,11 +226,17 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt // MARK: UI /// A default `NSParagraphStyle` with a set `lineHeight` - private var paragraphStyle: NSMutableParagraphStyle { + private lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + + private func generateParagraphStyle() -> NSMutableParagraphStyle { // swiftlint:disable:next force_cast let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight + // TODO: Fix Tab widths + // This adds tab stops throughout the document instead of only changing the width of tab characters +// paragraph.tabStops = [NSTextTab(type: .decimalTabStopType, location: 0.0)] +// paragraph.defaultTabInterval = CGFloat(tabWidth) * (" " as NSString).size(withAttributes: [.font: font]).width return paragraph } @@ -238,9 +256,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt internal func reloadUI() { // if font or baseline has been modified, set the hasSetStandardAttributesFlag // to false to ensure attributes are updated. This allows live UI updates when changing preferences. - if textView?.font != font || rulerView.baselineOffset != baselineOffset { - hasSetStandardAttributes = false - } textView?.textColor = theme.text textView.backgroundColor = useThemeBackground ? theme.background : .clear @@ -249,6 +264,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt textView?.selectedLineHighlightColor = theme.lineHighlight textView?.isEditable = isEditable textView.highlightSelectedLine = isEditable + textView?.typingAttributes = attributesFor(nil) + textView?.defaultParagraphStyle = paragraphStyle rulerView?.backgroundColor = useThemeBackground ? theme.background : .clear rulerView?.separatorColor = theme.invisibles @@ -267,15 +284,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt scrollView.contentInsets.bottom = bottomContentInsets + (contentInsets?.bottom ?? 0) } - setStandardAttributes() - } - - /// Sets the standard attributes (`font`, `baselineOffset`) to the whole text - internal func setStandardAttributes() { - guard let textView = textView else { return } - guard !hasSetStandardAttributes else { return } - hasSetStandardAttributes = true - textView.addAttributes(attributesFor(nil), range: .init(0.. Bool { + switch (lhs, rhs) { + case (.tab, .tab): + return true + case (.spaces(let lhsCount), .spaces(let rhsCount)): + return lhsCount == rhsCount + default: + return false + } + } +} diff --git a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift index fd93b952d..eb93cc13b 100644 --- a/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift +++ b/Sources/CodeEditTextView/Extensions/STTextView+/STTextView+TextInterface.swift @@ -42,5 +42,7 @@ extension STTextView: TextInterface { textContentStorage.performEditingTransaction { textContentStorage.applyMutation(mutation) } + + didChangeText() } } diff --git a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift index 95be78aec..b90a4dfcc 100644 --- a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift +++ b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift @@ -11,10 +11,10 @@ import TextStory /// Filter for quickly deleting indent whitespace struct DeleteWhitespaceFilter: Filter { - let indentationUnit: String + let indentOption: IndentOption func processMutation(_ mutation: TextMutation, in interface: TextInterface) -> FilterAction { - guard mutation.string == "" && mutation.range.length == 1 else { + guard mutation.string == "" && mutation.range.length == 1 && indentOption != .tab else { return .none } @@ -26,13 +26,14 @@ struct DeleteWhitespaceFilter: Filter { return .none } + let indentLength = indentOption.stringValue.count let length = mutation.range.max - preceedingNonWhitespace - let numberOfExtraSpaces = length % indentationUnit.count + let numberOfExtraSpaces = length % indentLength - if numberOfExtraSpaces == 0 && length >= indentationUnit.count { + if numberOfExtraSpaces == 0 && length >= indentLength { interface.applyMutation( - TextMutation(delete: NSRange(location: mutation.range.max - indentationUnit.count, - length: indentationUnit.count), + TextMutation(delete: NSRange(location: mutation.range.max - indentLength, + length: indentLength), limit: mutation.limit) ) return .discard diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 0e0ad7cac..74a65c7fd 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -18,7 +18,7 @@ extension STTextViewController { internal func setUpTextFormation() { textFilters = [] - let indentationUnit = String(repeating: " ", count: tabWidth) + let indentationUnit = indentOption.stringValue let pairsToHandle: [(String, String)] = [ ("{", "}"), @@ -38,9 +38,9 @@ extension STTextViewController { setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indentationUnit) + indentOption: indentOption) setUpDeletePairFilters(pairs: pairsToHandle) - setUpDeleteWhitespaceFilter(indentationUnit: indentationUnit) + setUpDeleteWhitespaceFilter(indentOption: indentOption) } /// Returns a `TextualIndenter` based on available language configuration. @@ -70,9 +70,9 @@ extension STTextViewController { /// - Parameters: /// - whitespaceProvider: The whitespace providers to use. /// - indentationUnit: The unit of indentation to use. - private func setUpNewlineTabFilters(whitespaceProvider: WhitespaceProviders, indentationUnit: String) { + private func setUpNewlineTabFilters(whitespaceProvider: WhitespaceProviders, indentOption: IndentOption) { let newlineFilter: Filter = NewlineProcessingFilter(whitespaceProviders: whitespaceProvider) - let tabReplacementFilter: Filter = TabReplacementFilter(indentationUnit: indentationUnit) + let tabReplacementFilter: Filter = TabReplacementFilter(indentOption: indentOption) textFilters.append(contentsOf: [newlineFilter, tabReplacementFilter]) } @@ -86,8 +86,8 @@ extension STTextViewController { } /// Configures up the delete whitespace filter. - private func setUpDeleteWhitespaceFilter(indentationUnit: String) { - let filter = DeleteWhitespaceFilter(indentationUnit: indentationUnit) + private func setUpDeleteWhitespaceFilter(indentOption: IndentOption) { + let filter = DeleteWhitespaceFilter(indentOption: indentOption) textFilters.append(filter) } diff --git a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift b/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift index c8db1f4b7..7c4aa8a5e 100644 --- a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift +++ b/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift @@ -12,11 +12,11 @@ import TextStory /// Filter for replacing tab characters with the user-defined indentation unit. /// - Note: The undentation unit can be another tab character, this is merely a point at which this can be configured. struct TabReplacementFilter: Filter { - let indentationUnit: String + let indentOption: IndentOption func processMutation(_ mutation: TextMutation, in interface: TextInterface) -> FilterAction { - if mutation.string == "\t" { - interface.applyMutation(TextMutation(insert: indentationUnit, + if mutation.string == "\t" && indentOption != .tab && mutation.delta > 0 { + interface.applyMutation(TextMutation(insert: indentOption.stringValue, at: mutation.range.location, limit: mutation.limit)) return .discard diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index c90d94e34..0f5045e14 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -2,6 +2,7 @@ import XCTest @testable import CodeEditTextView import SwiftTreeSitter import AppKit +import TextStory final class STTextViewControllerTests: XCTestCase { @@ -33,12 +34,15 @@ final class STTextViewControllerTests: XCTestCase { font: .monospacedSystemFont(ofSize: 11, weight: .medium), theme: theme, tabWidth: 4, + indentOption: .spaces(count: 4), wrapLines: true, cursorPosition: .constant((1, 1)), editorOverscroll: 0.5, useThemeBackground: true, isEditable: true ) + + controller.loadView() } func test_captureNames() throws { @@ -141,4 +145,52 @@ final class STTextViewControllerTests: XCTestCase { // editorOverscroll: 0 XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) } + + func test_indentOptionString() { + XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 3).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 4).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 5).stringValue) + + XCTAssertEqual("\t", IndentOption.tab.stringValue) + } + + func test_indentBehavior() { + // Insert 1 space + controller.indentOption = .spaces(count: 1) + controller.textView.string = "" + controller.insertTab(nil) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 2 spaces + controller.indentOption = .spaces(count: 2) + controller.textView.string = "" + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 3 spaces + controller.indentOption = .spaces(count: 3) + controller.textView.string = "" + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 4 spaces + controller.indentOption = .spaces(count: 4) + controller.textView.string = "" + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert tab + controller.indentOption = .tab + controller.textView.string = "" + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, "\t") + + // Insert lots of spaces + controller.indentOption = .spaces(count: 1000) + controller.textView.string = "" + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) + } }