From a81befb0609373d0f1d2b89aff78b6efe55ee359 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:25:38 -0500 Subject: [PATCH 1/6] Add Indent Options and Clarify Tab width --- .../CodeEditTextView/CodeEditTextView.swift | 16 +++++- .../Controller/STTextViewController.swift | 56 +++++++++++-------- .../CodeEditTextView/Enums/IndentOption.swift | 45 +++++++++++++++ .../STTextView+TextInterface.swift | 2 + .../Filters/DeleteWhitespaceFilter.swift | 13 +++-- .../STTextViewController+TextFormation.swift | 14 ++--- .../Filters/TabReplacementFilter.swift | 6 +- .../STTextViewControllerTests.swift | 53 ++++++++++++++++++ 8 files changed, 164 insertions(+), 41 deletions(-) create mode 100644 Sources/CodeEditTextView/Enums/IndentOption.swift diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 9a80e4bad..9765b1b59 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 inden 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..05dc0d6d6 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,53 @@ 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)) + } } From a09674676b87e741ad0c1709086de0f9466d4921 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:40:15 -0500 Subject: [PATCH 2/6] Add `Hashable` To IndentOption --- Sources/CodeEditTextView/Enums/IndentOption.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/Enums/IndentOption.swift b/Sources/CodeEditTextView/Enums/IndentOption.swift index a34b6a160..cc6f70c17 100644 --- a/Sources/CodeEditTextView/Enums/IndentOption.swift +++ b/Sources/CodeEditTextView/Enums/IndentOption.swift @@ -19,7 +19,7 @@ /// ```json /// { "tab": { } } /// ``` -public enum IndentOption: Equatable, Codable { +public enum IndentOption: Equatable, Codable, Hashable { case spaces(count: Int) case tab @@ -42,4 +42,8 @@ public enum IndentOption: Equatable, Codable { return false } } + + public func hash(into hasher: inout Hasher) { + hasher.combine(stringValue) + } } From ade770bb26e5c5f24ae572ad7d7ca7d650a4efd3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 27 Mar 2023 12:02:08 -0500 Subject: [PATCH 3/6] Simplify IndentOption --- .../CodeEditTextView/Enums/IndentOption.swift | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/Sources/CodeEditTextView/Enums/IndentOption.swift b/Sources/CodeEditTextView/Enums/IndentOption.swift index cc6f70c17..4904ba135 100644 --- a/Sources/CodeEditTextView/Enums/IndentOption.swift +++ b/Sources/CodeEditTextView/Enums/IndentOption.swift @@ -6,20 +6,7 @@ // /// Represents what to insert on a tab key press. -/// -/// Conforms to `Codable` with a JSON structure like below: -/// ```json -/// { -/// "spaces": { -/// "count": Int -/// } -/// } -/// ``` -/// or -/// ```json -/// { "tab": { } } -/// ``` -public enum IndentOption: Equatable, Codable, Hashable { +public enum IndentOption: Equatable { case spaces(count: Int) case tab @@ -42,8 +29,4 @@ public enum IndentOption: Equatable, Codable, Hashable { return false } } - - public func hash(into hasher: inout Hasher) { - hasher.combine(stringValue) - } } From 17af3f9d110d4e28a3d0ef8f91b1f157fb039e47 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 27 Mar 2023 12:09:36 -0500 Subject: [PATCH 4/6] Fix spelling error --- Sources/CodeEditTextView/CodeEditTextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 9765b1b59..aaff57aaf 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -105,7 +105,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.editorOverscroll = editorOverscroll controller.contentInsets = contentInsets - // Updating the language, theme, tab width and inden option 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 } From 6991e6896258917797d75a87fdc7db8f334f1720 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:25:38 -0500 Subject: [PATCH 5/6] Add Indent Options and Clarify Tab width --- .../CodeEditTextView/CodeEditTextView.swift | 16 +++++- .../Controller/STTextViewController.swift | 56 +++++++++++-------- .../CodeEditTextView/Enums/IndentOption.swift | 32 +++++++++++ .../STTextView+TextInterface.swift | 2 + .../Filters/DeleteWhitespaceFilter.swift | 13 +++-- .../STTextViewController+TextFormation.swift | 14 ++--- .../Filters/TabReplacementFilter.swift | 6 +- .../STTextViewControllerTests.swift | 53 ++++++++++++++++++ 8 files changed, 151 insertions(+), 41 deletions(-) create mode 100644 Sources/CodeEditTextView/Enums/IndentOption.swift 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..05dc0d6d6 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,53 @@ 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)) + } } From a4d9fa6bcbcb0c73816559575473ffd81e7b95e3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 27 Mar 2023 12:17:09 -0500 Subject: [PATCH 6/6] Fix lint error --- Tests/CodeEditTextViewTests/STTextViewControllerTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index 05dc0d6d6..0f5045e14 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -187,7 +187,6 @@ final class STTextViewControllerTests: XCTestCase { controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, "\t") - // Insert lots of spaces controller.indentOption = .spaces(count: 1000) controller.textView.string = ""