From 583af30e130a686e7ad212777338161b91cb8d36 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 17 Feb 2023 14:34:03 -0600 Subject: [PATCH 01/17] Respect user indent settings --- .../CodeEditTextView/CodeEditTextView.swift | 20 ++++++--- .../Filters/NewlineFilter.swift | 45 ------------------- .../STTextViewController+TextFormation.swift | 12 ++--- .../STTextViewController.swift | 21 +++++++-- 4 files changed, 39 insertions(+), 59 deletions(-) delete mode 100644 Sources/CodeEditTextView/Filters/NewlineFilter.swift diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 2ff103072..c719b7541 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -32,7 +32,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: CodeLanguage, theme: Binding, font: Binding, - tabWidth: Binding, + indentationWidth: Binding, + indentationUnit: Binding, lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), @@ -46,7 +47,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self._theme = theme self.useThemeBackground = useThemeBackground self._font = font - self._tabWidth = tabWidth + self._indentationWidth = indentationWidth + self._indentationUnit = indentationUnit self._lineHeight = lineHeight self._wrapLines = wrapLines self._editorOverscroll = editorOverscroll @@ -59,7 +61,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var language: CodeLanguage @Binding private var theme: EditorTheme @Binding private var font: NSFont - @Binding private var tabWidth: Int + @Binding private var indentationWidth: Int + @Binding private var indentationUnit: String @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @Binding private var editorOverscroll: Double @@ -76,7 +79,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: language, font: font, theme: theme, - tabWidth: tabWidth, + indentationWidth: indentationWidth, + indentationUnit: indentationUnit, wrapLines: wrapLines, cursorPosition: cursorPosition, editorOverscroll: editorOverscroll, @@ -90,7 +94,6 @@ 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 @@ -105,6 +108,13 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.theme = theme } + // Updating the indentation width/unit causes regeneration of text filters. + if controller.indentationWidth != indentationWidth || + controller.indentationUnit != indentationUnit { + controller.indentationWidth = indentationWidth + controller.indentationUnit = indentationUnit + } + controller.reloadUI() return } diff --git a/Sources/CodeEditTextView/Filters/NewlineFilter.swift b/Sources/CodeEditTextView/Filters/NewlineFilter.swift deleted file mode 100644 index b9ac913dc..000000000 --- a/Sources/CodeEditTextView/Filters/NewlineFilter.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// NewlineFilter.swift -// -// -// Created by Khan Winter on 1/28/23. -// - -import Foundation -import TextFormation -import TextStory - -/// A newline filter almost entirely similar to `TextFormation`s standard implementation. -struct NewlineFilter: Filter { - private let recognizer: ConsecutiveCharacterRecognizer - let providers: WhitespaceProviders - - init(whitespaceProviders: WhitespaceProviders) { - self.recognizer = ConsecutiveCharacterRecognizer(matching: "\n") - self.providers = whitespaceProviders - } - - func processMutation(_ mutation: TextStory.TextMutation, - in interface: TextFormation.TextInterface) -> TextFormation.FilterAction { - recognizer.processMutation(mutation) - - switch recognizer.state { - case .triggered: - return filterHandler(mutation, in: interface) - case .tracking, .idle: - return .none - } - } - - private func filterHandler(_ mutation: TextMutation, in interface: TextInterface) -> FilterAction { - interface.applyMutation(mutation) - - let range = NSRange(location: mutation.postApplyRange.max, length: 0) - - let value = providers.leadingWhitespace(range, interface) - - interface.insertString(value, at: mutation.postApplyRange.max) - - return .discard - } -} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 55bba4a2e..49efbb67b 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 indent = String(repeating: self.indentationUnit, count: indentationWidth) let pairsToHandle: [(String, String)] = [ ("{", "}"), @@ -29,8 +29,8 @@ extension STTextViewController { let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( - leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, - width: tabWidth), + leadingWhitespace: indenter.substitionProvider(indentationUnit: indent, + width: indentationWidth), trailingWhitespace: { _, _ in "" } ) @@ -38,9 +38,9 @@ extension STTextViewController { setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indentationUnit) + indentationUnit: indent) setUpDeletePairFilters(pairs: pairsToHandle) - setUpDeleteWhitespaceFilter(indentationUnit: indentationUnit) + setUpDeleteWhitespaceFilter(indentationUnit: indent) } /// Returns a `TextualIndenter` based on available language configuration. @@ -71,7 +71,7 @@ extension STTextViewController { /// - whitespaceProvider: The whitespace providers to use. /// - indentationUnit: The unit of indentation to use. private func setUpNewlineTabFilters(whitespaceProvider: WhitespaceProviders, indentationUnit: String) { - let newlineFilter: Filter = NewlineFilter(whitespaceProviders: whitespaceProvider) + let newlineFilter: Filter = NewlineProcessingFilter(whitespaceProviders: whitespaceProvider) let tabReplacementFilter: Filter = TabReplacementFilter(indentationUnit: indentationUnit) textFilters.append(contentsOf: [newlineFilter, tabReplacementFilter]) diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/STTextViewController.swift index 92a149952..bf7895a8e 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/STTextViewController.swift @@ -38,7 +38,20 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt public var useThemeBackground: Bool /// The number of spaces to use for a `tab '\t'` character - public var tabWidth: Int + /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. + public var indentationWidth: Int { + didSet { + setUpTextFormation() + } + } + + /// The string to use for tabs. Will by multiplied by `tabWidth` when inserted as an indent. + /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. + public var indentationUnit: String { + didSet { + setUpTextFormation() + } + } /// A multiplier for setting the line height. Defaults to `1.0` public var lineHeightMultiple: Double = 1.0 @@ -75,7 +88,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt language: CodeLanguage, font: NSFont, theme: EditorTheme, - tabWidth: Int, + indentationWidth: Int, + indentationUnit: String, wrapLines: Bool, cursorPosition: Published<(Int, Int)>.Publisher? = nil, editorOverscroll: Double, @@ -87,7 +101,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.language = language self.font = font self.theme = theme - self.tabWidth = tabWidth + self.indentationWidth = indentationWidth + self.indentationUnit = indentationUnit self.wrapLines = wrapLines self.cursorPosition = cursorPosition self.editorOverscroll = editorOverscroll From fcef3970fb52e6a28373b5965977485d0934cc9f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:45:08 -0600 Subject: [PATCH 02/17] Distinguish between indent unit and tab width --- .../CodeEditTextView/CodeEditTextView.swift | 20 +++++++++++-------- .../STTextViewController+TextFormation.swift | 10 ++++------ .../STTextViewController.swift | 15 ++++++-------- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index c719b7541..4673db3a2 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -20,6 +20,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - useThemeBackground: Whether CodeEditTextView uses theme background color or is transparent /// - font: The default font /// - tabWidth: The tab width + /// - indentationUnit: The string to use for indents. /// - 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`) @@ -32,7 +33,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: CodeLanguage, theme: Binding, font: Binding, - indentationWidth: Binding, + tabWidth: Binding, indentationUnit: Binding, lineHeight: Binding, wrapLines: Binding, @@ -47,7 +48,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self._theme = theme self.useThemeBackground = useThemeBackground self._font = font - self._indentationWidth = indentationWidth + self._tabWidth = tabWidth self._indentationUnit = indentationUnit self._lineHeight = lineHeight self._wrapLines = wrapLines @@ -61,7 +62,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var language: CodeLanguage @Binding private var theme: EditorTheme @Binding private var font: NSFont - @Binding private var indentationWidth: Int + @Binding private var tabWidth: Int @Binding private var indentationUnit: String @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @@ -79,7 +80,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: language, font: font, theme: theme, - indentationWidth: indentationWidth, + tabWidth: tabWidth, indentationUnit: indentationUnit, wrapLines: wrapLines, cursorPosition: cursorPosition, @@ -108,10 +109,13 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.theme = theme } - // Updating the indentation width/unit causes regeneration of text filters. - if controller.indentationWidth != indentationWidth || - controller.indentationUnit != indentationUnit { - controller.indentationWidth = indentationWidth + // Updating the tab width (will) reset the default paragraph style, needing a re-render. + if controller.tabWidth != tabWidth { + controller.tabWidth = tabWidth + } + + // Updating the indentation unit causes regeneration of text filters. + if controller.indentationUnit != indentationUnit { controller.indentationUnit = indentationUnit } diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 49efbb67b..052b6316d 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -18,8 +18,6 @@ extension STTextViewController { internal func setUpTextFormation() { textFilters = [] - let indent = String(repeating: self.indentationUnit, count: indentationWidth) - let pairsToHandle: [(String, String)] = [ ("{", "}"), ("[", "]"), @@ -29,8 +27,8 @@ extension STTextViewController { let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( - leadingWhitespace: indenter.substitionProvider(indentationUnit: indent, - width: indentationWidth), + leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, + width: indentationUnit.count), trailingWhitespace: { _, _ in "" } ) @@ -38,9 +36,9 @@ extension STTextViewController { setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indent) + indentationUnit: indentationUnit) setUpDeletePairFilters(pairs: pairsToHandle) - setUpDeleteWhitespaceFilter(indentationUnit: indent) + setUpDeleteWhitespaceFilter(indentationUnit: indentationUnit) } /// Returns a `TextualIndenter` based on available language configuration. diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/STTextViewController.swift index bf7895a8e..c8362e4fb 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/STTextViewController.swift @@ -37,13 +37,8 @@ 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 - /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. - public var indentationWidth: Int { - didSet { - setUpTextFormation() - } - } + /// The number of visual spaces to use for a `tab '\t'` character + public var tabWidth: Int /// The string to use for tabs. Will by multiplied by `tabWidth` when inserted as an indent. /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. @@ -88,7 +83,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt language: CodeLanguage, font: NSFont, theme: EditorTheme, - indentationWidth: Int, + tabWidth: Int, indentationUnit: String, wrapLines: Bool, cursorPosition: Published<(Int, Int)>.Publisher? = nil, @@ -101,7 +96,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.language = language self.font = font self.theme = theme - self.indentationWidth = indentationWidth + self.tabWidth = tabWidth self.indentationUnit = indentationUnit self.wrapLines = wrapLines self.cursorPosition = cursorPosition @@ -210,6 +205,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight +// paragraph.tabStops.removeAll() +// paragraph.defaultTabInterval = CGFloat(tabWidth) * " ".size(withAttributes: [.font: self.font]).width return paragraph } From c8f2741171125f5084dcfdf779d1eafd758a09e8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:56:24 -0600 Subject: [PATCH 03/17] Add defaults to initializers --- Sources/CodeEditTextView/CodeEditTextView.swift | 2 +- Sources/CodeEditTextView/STTextViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 4673db3a2..35d0c24f9 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -34,7 +34,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { theme: Binding, font: Binding, tabWidth: Binding, - indentationUnit: Binding, + indentationUnit: Binding = .constant(String(repeating: " ", count: 4)), lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/STTextViewController.swift index c8362e4fb..5bbf36f22 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/STTextViewController.swift @@ -84,7 +84,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt font: NSFont, theme: EditorTheme, tabWidth: Int, - indentationUnit: String, + indentationUnit: String = String(repeating: " ", count: 4), wrapLines: Bool, cursorPosition: Published<(Int, Int)>.Publisher? = nil, editorOverscroll: Double, From e7a082aa91fe0cb6c70a6cb7a1c4e634c5d6cef9 Mon Sep 17 00:00:00 2001 From: Renan Greca Date: Mon, 20 Feb 2023 16:24:27 +0100 Subject: [PATCH 04/17] Make cursor appear when clicking after the last line of a file (#56) (#145) --- Sources/CodeEditTextView/CEScrollView.swift | 28 +++++++++++++++++++ .../STTextViewController.swift | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 Sources/CodeEditTextView/CEScrollView.swift diff --git a/Sources/CodeEditTextView/CEScrollView.swift b/Sources/CodeEditTextView/CEScrollView.swift new file mode 100644 index 000000000..6e8dcc0c7 --- /dev/null +++ b/Sources/CodeEditTextView/CEScrollView.swift @@ -0,0 +1,28 @@ +// +// CEScrollView.swift +// +// +// Created by Renan Greca on 18/02/23. +// + +import AppKit +import STTextView + +class CEScrollView: NSScrollView { + + override func mouseDown(with event: NSEvent) { + + if let textView = self.documentView as? STTextView, + !textView.visibleRect.contains(event.locationInWindow) { + // If the `scrollView` was clicked, but the click did not happen within the `textView`, + // set cursor to the last index of the `textView`. + + let endLocation = textView.textLayoutManager.documentRange.endLocation + let range = NSTextRange(location: endLocation) + _ = textView.becomeFirstResponder() + textView.setSelectedRange(range) + } + + super.mouseDown(with: event) + } +} diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/STTextViewController.swift index a8a272824..56d6ecd2c 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/STTextViewController.swift @@ -107,7 +107,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt public override func loadView() { textView = STTextView() - let scrollView = NSScrollView() + let scrollView = CEScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.hasVerticalScroller = true scrollView.documentView = textView From c6d67a2a8e84688d706c3652d8e43e33fcf5f7d6 Mon Sep 17 00:00:00 2001 From: Lukas Pistrol Date: Fri, 10 Mar 2023 10:20:29 +0100 Subject: [PATCH 05/17] [chore]: add pr template, update issue template (#159) - issue template has been updated to reflect the templates in `CodeEdit` --- .github/ISSUE_TEMPLATE/bug_report.yml | 36 ++++++++++++---------- .github/ISSUE_TEMPLATE/feature_request.yml | 31 ++++++++----------- .github/pull_request_template.md | 30 ++++++++++++++++++ 3 files changed, 63 insertions(+), 34 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 136b1e507..d4a0b1e16 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,14 +1,14 @@ name: 🐞 Bug report description: Something is not working as expected. -title: 🐞 YOUR_DESCRIPTION +title: 🐞 labels: bug body: - type: textarea attributes: label: Description - description: >- - A clear and concise description of what the bug is. + placeholder: >- + A clear and concise description of what the bug is... validations: required: true @@ -16,7 +16,7 @@ body: attributes: label: To Reproduce description: >- - Steps to reproduce the behavior. + Steps to reliably reproduce the behavior. placeholder: | 1. Go to '...' 2. Click on '....' @@ -27,26 +27,30 @@ body: - type: textarea attributes: - label: Expected behavior - description: >- - A clear and concise description of what you expected to happen. + label: Expected Behavior + placeholder: >- + A clear and concise description of what you expected to happen... validations: required: true - type: textarea attributes: - label: Version information + label: Version Information description: >- click on the version number on the welcome screen value: | - CodeEditTextView: [e.g. 1.0] - macOS: [e.g. 12.3.0] - Xcode: [e.g. 13.3] - validations: - required: true + CodeEditTextView: [e.g. 0.x.y] + macOS: [e.g. 13.2.1] + Xcode: [e.g. 14.2] + + - type: textarea + attributes: + label: Additional Context + placeholder: >- + Any other context or considerations about the bug... - type: textarea attributes: - label: Additional context - description: >- - Add any other context about the problem here. + label: Screenshots + placeholder: >- + If applicable, please provide relevant screenshots or screen recordings... diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d861e8d3f..476a7721e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,36 +1,31 @@ name: ✨ Feature request description: Suggest an idea for this project -title: ✨ YOUR_DESCRIPTION +title: ✨ labels: enhancement body: - - type: input - attributes: - label: Is your feature request related to a problem? Please describe. - placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - validations: - required: false - - type: textarea attributes: - label: Describe the solution you'd like + label: Description placeholder: >- - A clear and concise description of what you want to happen. + A clear and concise description of what you would like to happen... validations: required: true - type: textarea attributes: - label: Describe alternatives you've considered + label: Alternatives Considered placeholder: >- - A clear and concise description of any alternative solutions or features you've considered. - validations: - required: true + Any alternative solutions or features you've considered... - type: textarea attributes: - label: Additional context + label: Additional Context placeholder: >- - Add any other context or screenshots about the feature request here. - validations: - required: true + Any other context or considerations about the feature request... + + - type: textarea + attributes: + label: Screenshots + placeholder: >- + If applicable, please provide relevant screenshots or screen recordings... diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..d976c3fa5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ + + +### Description + + + +### Related Issues + + + + + +* #ISSUE_NUMBER + +### Checklist + + + +- [ ] 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) +- [ ] The issues this PR addresses are related to each other +- [ ] My changes generate no new warnings +- [ ] My code builds and runs on my machine +- [ ] My changes are all related to the related issue above +- [ ] I documented my code + +### Screenshots + + + + From b0d23bf785aeeb1f7957713c93141f098fce5006 Mon Sep 17 00:00:00 2001 From: Miguel Ferreira Date: Tue, 14 Mar 2023 02:48:40 +0000 Subject: [PATCH 06/17] Fixes text binding update (#160) ### Description The STTextViewDelegate textDidChange function changed to textViewDidChangeText. Without this change, the text binding value wasn't updated. Before, in a situation like the one below, the prompt variable was never updated since the delegate function was not called because of the mismatched signature. ``` @State var prompt: String = "..." (...) CodeEditTextView( $prompt, language: .default, theme: $theme, font: $font, tabWidth: $tabWidth, lineHeight: $lineHeight, wrapLines: .constant(true), editorOverscroll: $editorOverscroll ) .onChange(of: prompt, perform: { promptUpdate in print(promptUpdate) }) ``` With the proposed update, the behaviour is as expected. The `onChange` is now triggered. The STTextViewDelegate: [https://github.com/krzyzanowskim/STTextView/blob/main/Sources/STTextView/STTextViewDelegate.swift](https://github.com/krzyzanowskim/STTextView/blob/main/Sources/STTextView/STTextViewDelegate.swift) ### Checklist - [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) - [ ] 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 - [ ] My changes are all related to the related issue above - [x] I documented my code Author: @miguel-arrf Approved by: @thecoolwinter --- Sources/CodeEditTextView/STTextViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/STTextViewController.swift index 56d6ecd2c..145ec42d6 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/STTextViewController.swift @@ -183,7 +183,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt super.viewDidAppear() } - public func textDidChange(_ notification: Notification) { + public func textViewDidChangeText(_ notification: Notification) { self.text.wrappedValue = textView.string } From c611a62a0ddc824ec2a32cc027e5028bfd2c5207 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 15 Mar 2023 14:29:15 -0500 Subject: [PATCH 07/17] Optionally set text view to read only (#161) --- Sources/CodeEditTextView/CodeEditTextView.swift | 9 +++++++-- .../CodeEditTextView/STTextViewController.swift | 17 ++++++++++++++++- .../STTextViewControllerTests.swift | 3 ++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 2ff103072..c2d9bb5d3 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -27,6 +27,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// built-in `TreeSitterClient` highlighter. /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. + /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. public init( _ text: Binding, language: CodeLanguage, @@ -39,7 +40,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { cursorPosition: Published<(Int, Int)>.Publisher? = nil, useThemeBackground: Bool = true, highlightProvider: HighlightProviding? = nil, - contentInsets: NSEdgeInsets? = nil + contentInsets: NSEdgeInsets? = nil, + isEditable: Bool = true ) { self._text = text self.language = language @@ -53,6 +55,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.cursorPosition = cursorPosition self.highlightProvider = highlightProvider self.contentInsets = contentInsets + self.isEditable = isEditable } @Binding private var text: String @@ -67,6 +70,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var useThemeBackground: Bool private var highlightProvider: HighlightProviding? private var contentInsets: NSEdgeInsets? + private var isEditable: Bool public typealias NSViewControllerType = STTextViewController @@ -82,7 +86,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { editorOverscroll: editorOverscroll, useThemeBackground: useThemeBackground, highlightProvider: highlightProvider, - contentInsets: contentInsets + contentInsets: contentInsets, + isEditable: isEditable ) controller.lineHeightMultiple = lineHeight return controller diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/STTextViewController.swift index 145ec42d6..179722af8 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/STTextViewController.swift @@ -52,6 +52,9 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt /// Whether lines wrap to the width of the editor public var wrapLines: Bool + /// Whether or not text view is editable by user + public var isEditable: Bool + /// Filters used when applying edits.. internal var textFilters: [TextFormation.Filter] = [] @@ -81,7 +84,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt editorOverscroll: Double, useThemeBackground: Bool, highlightProvider: HighlightProviding? = nil, - contentInsets: NSEdgeInsets? = nil + contentInsets: NSEdgeInsets? = nil, + isEditable: Bool ) { self.text = text self.language = language @@ -94,6 +98,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.useThemeBackground = useThemeBackground self.highlightProvider = highlightProvider self.contentInsets = contentInsets + self.isEditable = isEditable super.init(nibName: nil, bundle: nil) } @@ -123,6 +128,11 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt rulerView.drawSeparator = false rulerView.baselineOffset = baselineOffset rulerView.font = NSFont.monospacedDigitSystemFont(ofSize: 9.5, weight: .regular) + + if self.isEditable == false { + rulerView.selectedLineTextColor = nil + } + scrollView.verticalRulerView = rulerView scrollView.rulersVisible = true @@ -135,11 +145,13 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt textView.selectionBackgroundColor = theme.selection textView.selectedLineHighlightColor = theme.lineHighlight textView.string = self.text.wrappedValue + textView.isEditable = self.isEditable textView.widthTracksTextView = self.wrapLines textView.highlightSelectedLine = true textView.allowsUndo = true textView.setupMenus() textView.delegate = self + textView.highlightSelectedLine = self.isEditable scrollView.documentView = textView @@ -223,10 +235,13 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt textView?.insertionPointColor = theme.insertionPoint textView?.selectionBackgroundColor = theme.selection textView?.selectedLineHighlightColor = theme.lineHighlight + textView?.isEditable = isEditable + textView.highlightSelectedLine = isEditable rulerView?.backgroundColor = useThemeBackground ? theme.background : .clear rulerView?.separatorColor = theme.invisibles rulerView?.baselineOffset = baselineOffset + rulerView.highlightSelectedLine = isEditable if let scrollView = view as? NSScrollView { scrollView.drawsBackground = useThemeBackground diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index b170b46f1..5a71412e5 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -35,7 +35,8 @@ final class STTextViewControllerTests: XCTestCase { tabWidth: 4, wrapLines: true, editorOverscroll: 0.5, - useThemeBackground: true + useThemeBackground: true, + isEditable: true ) } From 0e3ca03dafe512f70a10a2a9f56810e92998d685 Mon Sep 17 00:00:00 2001 From: Elias Wahl <82230675+Eliulm@users.noreply.github.com> Date: Thu, 16 Mar 2023 00:23:51 +0100 Subject: [PATCH 08/17] New Feature: Compute the cursor position (#134) In essence, the line is calculated by dividing the y-position of the text segment with the cursor by the line height: ```swift var line = Int(textSegmentFrame.maxY / textSegmentFrame.height) ``` However, this counts the preceding line wraps as lines too. As a result, I have to count the preceding line wraps with: ```swift textLayoutManager.enumerateTextLayoutFragments(from: textLayoutManager.documentRange.location, options: [.ensuresLayout, .ensuresExtraLineFragment] ) { textLayoutFragment in guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation) else { return false } /// Check whether the textLayoutFragment has line wraps if textLayoutFragment.textLineFragments.count > 1 { for lineFragment in textLayoutFragment.textLineFragments { lineWrapsCount += 1 /// Do not count lineFragments after the lineFragment where the cursor is placed if lineFragment == cursorTextLineFragment { break } } /// The first lineFragment will be counted as an actual line lineWrapsCount -= 1 } if textLayoutFragment.textLineFragments.contains(cursorTextLineFragment) { return false } return true } ``` Unfortunately, this does scale with the line count of the file. So we might want to change that if we can come up with a better alternative in the future. As a first implementation, I think it works. --------- Co-authored-by: Lukas Pistrol --- .../CodeEditTextView/CodeEditTextView.swift | 9 +- .../STTextViewController+Cursor.swift | 103 ++++++++++++++++++ .../STTextViewController.swift | 56 +++------- .../STTextViewControllerTests.swift | 1 + 4 files changed, 121 insertions(+), 48 deletions(-) create mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift rename Sources/CodeEditTextView/{ => Controller}/STTextViewController.swift (86%) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index c2d9bb5d3..9a80e4bad 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -17,7 +17,6 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - text: The text content /// - language: The language for syntax highlighting /// - theme: The theme for syntax highlighting - /// - useThemeBackground: Whether CodeEditTextView uses theme background color or is transparent /// - font: The default font /// - tabWidth: The tab width /// - lineHeight: The line height multiplier (e.g. `1.2`) @@ -37,7 +36,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), - cursorPosition: Published<(Int, Int)>.Publisher? = nil, + cursorPosition: Binding<(Int, Int)>, useThemeBackground: Bool = true, highlightProvider: HighlightProviding? = nil, contentInsets: NSEdgeInsets? = nil, @@ -52,7 +51,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self._lineHeight = lineHeight self._wrapLines = wrapLines self._editorOverscroll = editorOverscroll - self.cursorPosition = cursorPosition + self._cursorPosition = cursorPosition self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable @@ -66,7 +65,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @Binding private var editorOverscroll: Double - private var cursorPosition: Published<(Int, Int)>.Publisher? + @Binding private var cursorPosition: (Int, Int) private var useThemeBackground: Bool private var highlightProvider: HighlightProviding? private var contentInsets: NSEdgeInsets? @@ -82,7 +81,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { theme: theme, tabWidth: tabWidth, wrapLines: wrapLines, - cursorPosition: cursorPosition, + cursorPosition: $cursorPosition, editorOverscroll: editorOverscroll, useThemeBackground: useThemeBackground, highlightProvider: highlightProvider, diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift new file mode 100644 index 000000000..84160cfba --- /dev/null +++ b/Sources/CodeEditTextView/Controller/STTextViewController+Cursor.swift @@ -0,0 +1,103 @@ +// +// STTextViewController+Cursor.swift +// +// +// Created by Elias Wahl on 15.03.23. +// + +import Foundation +import AppKit + +extension STTextViewController { + func setCursorPosition(_ position: (Int, Int)) { + guard let provider = textView.textLayoutManager.textContentManager else { + return + } + + var (line, column) = position + let string = textView.string + if line > 0 { + if string.isEmpty { + // If the file is blank, automatically place the cursor in the first index. + let range = NSRange(string.startIndex.. Bool + in + var col = 1 + /// If the cursor is at the end of the document: + if textLayoutManager.offset(from: insertionPointLocation, to: documentEndLocation) == 0 { + /// If document is empty: + if textLayoutManager.offset(from: documentStartLocation, to: documentEndLocation) == 0 { + self.cursorPosition.wrappedValue = (1, 1) + return false + } + guard let cursorTextFragment = textLayoutManager.textLayoutFragment(for: textSegmentFrame.origin), + let cursorTextLineFragment = cursorTextFragment.textLineFragments.last + else { return false } + + col = cursorTextLineFragment.characterRange.length + 1 + if col == 1 { line += 1 } + } else { + guard let cursorTextLineFragment = textLayoutManager.textLineFragment(at: insertionPointLocation) + else { return false } + + /// +1, because we start with the first character with 1 + let tempCol = cursorTextLineFragment.characterIndex(for: textSegmentFrame.origin) + let result = tempCol.addingReportingOverflow(1) + + if !result.overflow { col = result.partialValue } + /// If cursor is at end of line add 1: + if cursorTextLineFragment.characterRange.length != 1 && + (cursorTextLineFragment.typographicBounds.width == (textSegmentFrame.maxX + 5.0)) { + col += 1 + } + + /// If cursor is at first character of line, the current line is not being included + if col == 1 { line += 1 } + } + + self.cursorPosition.wrappedValue = (line, col) + return false + } + } +} diff --git a/Sources/CodeEditTextView/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift similarity index 86% rename from Sources/CodeEditTextView/STTextViewController.swift rename to Sources/CodeEditTextView/Controller/STTextViewController.swift index 179722af8..0dbee4f5a 100644 --- a/Sources/CodeEditTextView/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -46,6 +46,9 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt /// The font to use in the `textView` public var font: NSFont + /// The current cursor position e.g. (1, 1) + public var cursorPosition: Binding<(Int, Int)> + /// The editorOverscroll to use for the textView over scroll public var editorOverscroll: Double @@ -80,7 +83,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt theme: EditorTheme, tabWidth: Int, wrapLines: Bool, - cursorPosition: Published<(Int, Int)>.Publisher? = nil, + cursorPosition: Binding<(Int, Int)>, editorOverscroll: Double, useThemeBackground: Bool, highlightProvider: HighlightProviding? = nil, @@ -175,9 +178,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt setHighlightProvider(self.highlightProvider) setUpTextFormation() - self.cursorPositionCancellable = self.cursorPosition?.sink(receiveValue: { value in - self.setCursorPosition(value) - }) + self.setCursorPosition(self.cursorPosition.wrappedValue) } public override func viewDidLoad() { @@ -189,6 +190,14 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt guard let self = self else { return } (self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets } + + NotificationCenter.default.addObserver( + forName: STTextView.didChangeSelectionNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateCursorPosition() + } } public override func viewDidAppear() { @@ -323,45 +332,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt // TODO: - This should be uncessecary } - // MARK: Cursor Position - - private var cursorPosition: Published<(Int, Int)>.Publisher? - private var cursorPositionCancellable: AnyCancellable? - - private func setCursorPosition(_ position: (Int, Int)) { - guard let provider = textView.textLayoutManager.textContentManager else { - return - } - - var (line, column) = position - let string = textView.string - if line > 0 { - if string.isEmpty { - // If the file is blank, automatically place the cursor in the first index. - let range = NSRange(string.startIndex.. Date: Thu, 16 Mar 2023 12:03:24 -0500 Subject: [PATCH 09/17] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 87061762f..b0f4e3fa3 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,13 @@ Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/ma -

CodeEdit

+

        CodeEdit        

-

CodeEditKit

+

     CodeEditKit     

@@ -103,7 +103,7 @@ Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/ma -

CodeEdit CLI

+

    CodeEdit CLI    

From 4ff2df56b94c822e834353ce9f74836f700bff1c Mon Sep 17 00:00:00 2001 From: Elias Wahl <82230675+Eliulm@users.noreply.github.com> Date: Thu, 16 Mar 2023 23:35:27 +0100 Subject: [PATCH 10/17] Make ruler look more like Xcode. (#163) ### Description 1. Add 20 pixels of inset to the left of the ruler and 8 pixels of inset to the right of the ruler. I tried to match it as close as possible to Xcode. 2. Set the background color of the highlight ruler line to the same color as the highlighted line in the textView. ### Related Issues * closes #156 * closes #128 ### Checklist - [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 ### Screenshots Screenshot 2023-03-16 at 19 05 59 --- .../CodeEditTextView/Controller/STTextViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 0dbee4f5a..8f8986716 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -131,9 +131,12 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt rulerView.drawSeparator = false rulerView.baselineOffset = baselineOffset rulerView.font = NSFont.monospacedDigitSystemFont(ofSize: 9.5, weight: .regular) + rulerView.selectedLineHighlightColor = theme.lineHighlight + rulerView.rulerInsets = STRulerInsets(leading: 20, trailing: 8) if self.isEditable == false { rulerView.selectedLineTextColor = nil + rulerView.selectedLineHighlightColor = theme.background } scrollView.verticalRulerView = rulerView @@ -249,6 +252,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt rulerView?.backgroundColor = useThemeBackground ? theme.background : .clear rulerView?.separatorColor = theme.invisibles + rulerView?.selectedLineHighlightColor = theme.lineHighlight rulerView?.baselineOffset = baselineOffset rulerView.highlightSelectedLine = isEditable From 36a5f790153d9feae0221ba40e0b9da2a1211232 Mon Sep 17 00:00:00 2001 From: Lukas Pistrol Date: Thu, 23 Mar 2023 17:12:08 +0100 Subject: [PATCH 11/17] [chore]: Update CodeEditLanguages to `0.1.13` (#167) --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index cf0b8e192..f58b470f3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", "state" : { - "revision" : "02595fb02841568b9befcbfcdaf9be88280c49aa", - "version" : "0.1.11" + "revision" : "08cb9dc04e70d1e7b9610580794ed4da774c2b84", + "version" : "0.1.13" } }, { diff --git a/Package.swift b/Package.swift index 5c1461071..41edcf0f8 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( ), .package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", - exact: "0.1.11" + exact: "0.1.13" ), .package( url: "https://github.com/lukepistrol/SwiftLintPlugin", From b11be4d3454669f0ef9f3c567ea7c74b6ef1d9af Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 24 Mar 2023 18:35:19 -0500 Subject: [PATCH 12/17] Add Injected Language Support (#150) # Description This PR adds support for injected languages using tree-sitter as described in #16 and in the [tree-sitter documentation](https://tree-sitter.github.io/tree-sitter/syntax-highlighting#language-injection). Languages can contain _injected_ languages which are described in a language's `injections.scm` file. Some examples of injected languages are: - HTML contains CSS and Javascript in `style` and `script` tags - Javascript contains Regex literals - PHP contains HTML between the ` With Injected languages, in this case CSS and JS embedded in HTML and a second layer of Regex embedded in JS embedded in HTML: Screenshot 2023-03-24 at 2 45 15 PM --- .../Controller/STTextViewController.swift | 2 +- .../NSRange+/NSRange+Comparable.swift | 18 + .../NSRange+/NSRange+InputEdit.swift | 32 ++ .../Extensions/NSRange+/NSRange+TSRange.swift | 18 + .../Extensions/Tree+prettyPrint.swift | 71 ++++ .../STTextViewController+TextFormation.swift | 2 +- .../Highlighting/HighlightProviding.swift | 3 + .../Highlighting/Highlighter.swift | 4 +- .../TreeSitter/TreeSitterClient+Edit.swift | 163 ++++++++ .../TreeSitterClient+Highlight.swift | 92 +++++ .../TreeSitterClient+LanguageLayer.swift | 54 +++ .../TreeSitter/TreeSitterClient.swift | 351 +++++++++++------- 12 files changed, 668 insertions(+), 142 deletions(-) create mode 100644 Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift create mode 100644 Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift create mode 100644 Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift create mode 100644 Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift create mode 100644 Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift create mode 100644 Sources/CodeEditTextView/TreeSitter/TreeSitterClient+LanguageLayer.swift diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 8f8986716..cc7eeea53 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -320,7 +320,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt return self?.textView.textContentStorage.textStorage?.mutableString.substring(with: range) } - provider = try? TreeSitterClient(codeLanguage: language, textProvider: textProvider) + provider = TreeSitterClient(codeLanguage: language, textProvider: textProvider) } if let provider = provider { diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift new file mode 100644 index 000000000..15c7e3b12 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+Comparable.swift @@ -0,0 +1,18 @@ +// +// NSRange+Comparable.swift +// +// +// Created by Khan Winter on 3/15/23. +// + +import Foundation + +extension NSRange: Comparable { + public static func == (lhs: NSRange, rhs: NSRange) -> Bool { + return lhs.location == rhs.location && lhs.length == rhs.length + } + + public static func < (lhs: NSRange, rhs: NSRange) -> Bool { + return lhs.location < rhs.location + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift index 9994a92df..ee8018de5 100644 --- a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+InputEdit.swift @@ -29,3 +29,35 @@ extension InputEdit { newEndPoint: newEndPoint) } } + +extension NSRange { + // swiftlint:disable line_length + /// Modifies the range to account for an edit. + /// Largely based on code from + /// [tree-sitter](https://github.com/tree-sitter/tree-sitter/blob/ddeaa0c7f534268b35b4f6cb39b52df082754413/lib/src/subtree.c#L691-L720) + mutating func applyInputEdit(_ edit: InputEdit) { + // swiftlint:enable line_length + let endIndex = NSMaxRange(self) + let isPureInsertion = edit.oldEndByte == edit.startByte + + // Edit is after the range + if (edit.startByte/2) > endIndex { + return + } else if edit.oldEndByte/2 < location { + // If the edit is entirely before this range + self.location += (Int(edit.newEndByte) - Int(edit.oldEndByte))/2 + } else if edit.startByte/2 < location { + // If the edit starts in the space before this range and extends into this range + length -= Int(edit.oldEndByte)/2 - location + location = Int(edit.newEndByte)/2 + } else if edit.startByte/2 == location && isPureInsertion { + // If the edit is *only* an insertion right at the beginning of the range + location = Int(edit.newEndByte)/2 + } else { + // Otherwise, the edit is entirely within this range + if edit.startByte/2 < endIndex || (edit.startByte/2 == endIndex && isPureInsertion) { + length = (Int(edit.newEndByte)/2 - location) + (length - (Int(edit.oldEndByte)/2 - location)) + } + } + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift new file mode 100644 index 000000000..a0273784e --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+TSRange.swift @@ -0,0 +1,18 @@ +// +// NSRange+TSRange.swift +// +// +// Created by Khan Winter on 2/26/23. +// + +import Foundation +import SwiftTreeSitter + +extension NSRange { + var tsRange: TSRange { + return TSRange( + points: .zero..<(.zero), + bytes: (UInt32(self.location) * 2)..<(UInt32(self.location + self.length) * 2) + ) + } +} diff --git a/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift b/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift new file mode 100644 index 000000000..a021733c9 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/Tree+prettyPrint.swift @@ -0,0 +1,71 @@ +// +// Tree+prettyPrint.swift +// +// +// Created by Khan Winter on 3/16/23. +// + +import SwiftTreeSitter + +#if DEBUG +extension Tree { + func prettyPrint() { + guard let cursor = self.rootNode?.treeCursor else { + print("NO ROOT NODE") + return + } + guard cursor.currentNode != nil else { + print("NO CURRENT NODE") + return + } + + func p(_ cursor: TreeCursor, depth: Int) { + guard let node = cursor.currentNode else { + return + } + + let visible = node.isNamed + + if visible { + print(String(repeating: " ", count: depth * 2), terminator: "") + if let fieldName = cursor.currentFieldName { + print(fieldName, ": ", separator: "", terminator: "") + } + print("(", node.nodeType ?? "NONE", " ", node.range, " ", separator: "", terminator: "") + } + + if cursor.goToFirstChild() { + while true { + if cursor.currentNode != nil && cursor.currentNode!.isNamed { + print("") + } + + p(cursor, depth: depth + 1) + + if !cursor.gotoNextSibling() { + break + } + } + + if !cursor.gotoParent() { + fatalError("Could not go to parent, this tree may be invalid.") + } + } + + if visible { + print(")", terminator: "") + } + } + + if cursor.currentNode?.childCount == 0 { + if !cursor.currentNode!.isNamed { + print("{\(cursor.currentNode!.nodeType ?? "NONE")}") + } else { + print("\"\(cursor.currentNode!.nodeType ?? "NONE")\"") + } + } else { + p(cursor, depth: 1) + } + } +} +#endif diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 55bba4a2e..0e0ad7cac 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -71,7 +71,7 @@ extension STTextViewController { /// - whitespaceProvider: The whitespace providers to use. /// - indentationUnit: The unit of indentation to use. private func setUpNewlineTabFilters(whitespaceProvider: WhitespaceProviders, indentationUnit: String) { - let newlineFilter: Filter = NewlineFilter(whitespaceProviders: whitespaceProvider) + let newlineFilter: Filter = NewlineProcessingFilter(whitespaceProviders: whitespaceProvider) let tabReplacementFilter: Filter = TabReplacementFilter(indentationUnit: indentationUnit) textFilters.append(contentsOf: [newlineFilter, tabReplacementFilter]) diff --git a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift index cd376ef9f..199c2cc42 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift +++ b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift @@ -17,6 +17,9 @@ public protocol HighlightProviding { /// - Note: This does not need to be *globally* unique, merely unique across all the highlighters used. var identifier: String { get } + /// Called once at editor initialization. + func setUp(textView: HighlighterTextView) + /// Updates the highlighter's code language. /// - Parameters: /// - codeLanguage: The langugage that should be used by the highlighter. diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift index fe4990bca..ebda49b97 100644 --- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift +++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift @@ -88,6 +88,7 @@ class Highlighter: NSObject { } textView.textContentStorage.textStorage?.delegate = self + highlightProvider?.setUp(textView: textView) if let scrollView = textView.enclosingScrollView { NotificationCenter.default.addObserver(self, @@ -121,6 +122,7 @@ class Highlighter: NSObject { public func setHighlightProvider(_ provider: HighlightProviding) { self.highlightProvider = provider highlightProvider?.setLanguage(codeLanguage: language) + highlightProvider?.setUp(textView: textView) invalidate() } @@ -282,7 +284,7 @@ extension Highlighter: NSTextStorageDelegate { delta: delta) { [weak self] invalidatedIndexSet in let indexSet = invalidatedIndexSet .union(IndexSet(integersIn: editedRange)) - // Only invalidate indices that aren't visible. + // Only invalidate indices that are visible. .intersection(self?.visibleSet ?? .init()) for range in indexSet.rangeView { diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift new file mode 100644 index 000000000..b7a31329e --- /dev/null +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift @@ -0,0 +1,163 @@ +// +// TreeSitterClient+Edit.swift +// +// +// Created by Khan Winter on 3/10/23. +// + +import Foundation +import SwiftTreeSitter +import CodeEditLanguages + +extension TreeSitterClient { + + /// Calculates a series of ranges that have been invalidated by a given edit. + /// - Parameters: + /// - textView: The text view to use for text. + /// - edit: The edit to act on. + /// - language: The language to use. + /// - readBlock: A callback for fetching blocks of text. + /// - Returns: An array of distinct `NSRanges` that need to be re-highlighted. + func findChangedByteRanges( + textView: HighlighterTextView, + edit: InputEdit, + layer: LanguageLayer, + readBlock: @escaping Parser.ReadBlock + ) -> [NSRange] { + let (oldTree, newTree) = calculateNewState( + tree: layer.tree, + parser: layer.parser, + edit: edit, + readBlock: readBlock + ) + if oldTree == nil && newTree == nil { + // There was no existing tree, make a new one and return all indexes. + layer.tree = createTree(parser: layer.parser, readBlock: readBlock) + return [NSRange(textView.documentRange.intRange)] + } + + let ranges = changedByteRanges(oldTree, rhs: newTree).map { $0.range } + + layer.tree = newTree + + return ranges + } + + /// Applies the edit to the current `tree` and returns the old tree and a copy of the current tree with the + /// processed edit. + /// - Parameters: + /// - tree: The tree before an edit used to parse the new tree. + /// - parser: The parser used to parse the new tree. + /// - edit: The edit to apply. + /// - readBlock: The block to use to read text. + /// - Returns: (The old state, the new state). + internal func calculateNewState( + tree: Tree?, + parser: Parser, + edit: InputEdit, + readBlock: @escaping Parser.ReadBlock + ) -> (Tree?, Tree?) { + guard let oldTree = tree else { + return (nil, nil) + } + semaphore.wait() + + // Apply the edit to the old tree + oldTree.edit(edit) + + let newTree = parser.parse(tree: oldTree, readBlock: readBlock) + + semaphore.signal() + + return (oldTree.copy(), newTree) + } + + /// Calculates the changed byte ranges between two trees. + /// - Parameters: + /// - lhs: The first (older) tree. + /// - rhs: The second (newer) tree. + /// - Returns: Any changed ranges. + internal func changedByteRanges(_ lhs: Tree?, rhs: Tree?) -> [Range] { + switch (lhs, rhs) { + case (let t1?, let t2?): + return t1.changedRanges(from: t2).map({ $0.bytes }) + case (nil, let t2?): + let range = t2.rootNode?.byteRange + + return range.flatMap({ [$0] }) ?? [] + case (_, nil): + return [] + } + } + + /// Performs an injections query on the given language layer. + /// Updates any existing layers with new ranges and adds new layers if needed. + /// - Parameters: + /// - textView: The text view to use. + /// - layer: The language layer to perform the query on. + /// - layerSet: The set of layers that exist in the document. + /// Used for efficient lookup of existing `(language, range)` pairs + /// - touchedLayers: The set of layers that existed before updating injected layers. + /// Will have items removed as they are found. + /// - readBlock: A completion block for reading from text storage efficiently. + /// - Returns: An index set of any updated indexes. + @discardableResult + internal func updateInjectedLanguageLayers( + textView: HighlighterTextView, + layer: LanguageLayer, + layerSet: inout Set, + touchedLayers: inout Set, + readBlock: @escaping Parser.ReadBlock + ) -> IndexSet { + guard let tree = layer.tree, + let rootNode = tree.rootNode, + let cursor = layer.languageQuery?.execute(node: rootNode, in: tree) else { + return IndexSet() + } + + cursor.matchLimit = Constants.treeSitterMatchLimit + + let languageRanges = self.injectedLanguagesFrom(cursor: cursor) { range, _ in + return textView.stringForRange(range) + } + + var updatedRanges = IndexSet() + + for (languageName, ranges) in languageRanges { + guard let treeSitterLanguage = TreeSitterLanguage(rawValue: languageName) else { + continue + } + + if treeSitterLanguage == primaryLayer { + continue + } + + for range in ranges { + // Temp layer object for + let layer = LanguageLayer( + id: treeSitterLanguage, + parser: Parser(), + supportsInjections: false, + ranges: [range.range] + ) + + if layerSet.contains(layer) { + // If we've found this layer, it means it should exist after an edit. + touchedLayers.remove(layer) + } else { + // New range, make a new layer! + if let addedLayer = addLanguageLayer(layerId: treeSitterLanguage, readBlock: readBlock) { + addedLayer.ranges = [range.range] + addedLayer.parser.includedRanges = addedLayer.ranges.map { $0.tsRange } + addedLayer.tree = createTree(parser: addedLayer.parser, readBlock: readBlock) + + layerSet.insert(addedLayer) + updatedRanges.insert(range: range.range) + } + } + } + } + + return updatedRanges + } +} diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift new file mode 100644 index 000000000..de8764f0a --- /dev/null +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Highlight.swift @@ -0,0 +1,92 @@ +// +// TreeSitterClient+Highlight.swift +// +// +// Created by Khan Winter on 3/10/23. +// + +import Foundation +import SwiftTreeSitter +import CodeEditLanguages + +extension TreeSitterClient { + + /// Queries the given language layer for any highlights. + /// - Parameters: + /// - layer: The layer to query. + /// - textView: A text view to use for contextual data. + /// - range: The range to query for. + /// - Returns: Any ranges to highlight. + internal func queryLayerHighlights( + layer: LanguageLayer, + textView: HighlighterTextView, + range: NSRange + ) -> [HighlightRange] { + // Make sure we don't change the tree while we copy it. + self.semaphore.wait() + + guard let tree = layer.tree?.copy() else { + self.semaphore.signal() + return [] + } + + self.semaphore.signal() + + guard let rootNode = tree.rootNode else { + return [] + } + + // This needs to be on the main thread since we're going to use the `textProvider` in + // the `highlightsFromCursor` method, which uses the textView's text storage. + guard let cursor = layer.languageQuery?.execute(node: rootNode, in: tree) else { + return [] + } + cursor.setRange(range) + cursor.matchLimit = Constants.treeSitterMatchLimit + + return highlightsFromCursor(cursor: ResolvingQueryCursor(cursor: cursor)) + } + + /// Resolves a query cursor to the highlight ranges it contains. + /// **Must be called on the main thread** + /// - Parameter cursor: The cursor to resolve. + /// - Returns: Any highlight ranges contained in the cursor. + internal func highlightsFromCursor(cursor: ResolvingQueryCursor) -> [HighlightRange] { + cursor.prepare(with: self.textProvider) + return cursor + .flatMap { $0.captures } + .compactMap { + // Some languages add an "@spell" capture to indicate a portion of text that should be spellchecked + // (usually comments). But this causes other captures in the same range to be overriden. So we ignore + // that specific capture type. + if $0.name != "spell" && $0.name != "injection.content" { + return HighlightRange(range: $0.range, capture: CaptureName.fromString($0.name ?? "")) + } + return nil + } + } + + /// Returns all injected languages from a given cursor. The cursor must be new, + /// having not been used for normal highlight matching. + /// - Parameters: + /// - cursor: The cursor to use for finding injected languages. + /// - textProvider: A callback for efficiently fetching text. + /// - Returns: A map of each language to all the ranges they have been injected into. + internal func injectedLanguagesFrom( + cursor: QueryCursor, + textProvider: @escaping ResolvingQueryCursor.TextProvider + ) -> [String: [NamedRange]] { + var languages: [String: [NamedRange]] = [:] + + for match in cursor { + if let injection = match.injection(with: textProvider) { + if languages[injection.name] == nil { + languages[injection.name] = [] + } + languages[injection.name]?.append(injection) + } + } + + return languages + } +} diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+LanguageLayer.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+LanguageLayer.swift new file mode 100644 index 000000000..c5ab2cc15 --- /dev/null +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+LanguageLayer.swift @@ -0,0 +1,54 @@ +// +// TreeSitterClient+LanguageLayer.swift +// +// +// Created by Khan Winter on 3/8/23. +// + +import Foundation +import CodeEditLanguages +import SwiftTreeSitter + +extension TreeSitterClient { + class LanguageLayer: Hashable { + /// Initialize a language layer + /// - Parameters: + /// - id: The ID of the layer. + /// - parser: A parser to use for the layer. + /// - supportsInjections: Set to true when the langauge supports the `injections` query. + /// - tree: The tree-sitter tree generated while editing/parsing a document. + /// - languageQuery: The language query used for fetching the associated `queries.scm` file + /// - ranges: All ranges this layer acts on. Must be kept in order and w/o overlap. + init( + id: TreeSitterLanguage, + parser: Parser, + supportsInjections: Bool, + tree: Tree? = nil, + languageQuery: Query? = nil, + ranges: [NSRange] + ) { + self.id = id + self.parser = parser + self.supportsInjections = supportsInjections + self.tree = tree + self.languageQuery = languageQuery + self.ranges = ranges + } + + let id: TreeSitterLanguage + let parser: Parser + let supportsInjections: Bool + var tree: Tree? + var languageQuery: Query? + var ranges: [NSRange] + + static func == (lhs: TreeSitterClient.LanguageLayer, rhs: TreeSitterClient.LanguageLayer) -> Bool { + return lhs.id == rhs.id && lhs.ranges == rhs.ranges + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(ranges) + } + } +} diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift index eea44f160..66276576f 100644 --- a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient.swift @@ -18,46 +18,91 @@ import SwiftTreeSitter /// However, the `setText` method will re-compile the entire corpus so should be used sparingly. final class TreeSitterClient: HighlightProviding { + // MARK: - Properties/Constants + public var identifier: String { "CodeEdit.TreeSitterClient" } - internal var parser: Parser - internal var tree: Tree? - internal var languageQuery: Query? - - private var textProvider: ResolvingQueryCursor.TextProvider + internal var primaryLayer: TreeSitterLanguage + internal var layers: [LanguageLayer] = [] - /// The queue to do tree-sitter work on for large edits/queries - private let queue: DispatchQueue = DispatchQueue(label: "CodeEdit.CodeEditTextView.TreeSitter", - qos: .userInteractive) + internal var textProvider: ResolvingQueryCursor.TextProvider /// Used to ensure safe use of the shared tree-sitter tree state in different sync/async contexts. - private let semaphore: DispatchSemaphore = DispatchSemaphore(value: 1) + internal let semaphore: DispatchSemaphore = DispatchSemaphore(value: 1) + + internal enum Constants { + /// The maximum amount of limits a cursor can match during a query. + /// Used to ensure performance in large files, even though we generally limit the query to the visible range. + /// Neovim encountered this issue and uses 64 for their limit. Helix uses 256 due to issues with some + /// languages when using 64. + /// See: https://github.com/neovim/neovim/issues/14897 + /// And: https://github.com/helix-editor/helix/pull/4830 + static let treeSitterMatchLimit = 256 + } + + // MARK: - Init/Config /// Initializes the `TreeSitterClient` with the given parameters. /// - Parameters: /// - codeLanguage: The language to set up the parser with. /// - textProvider: The text provider callback to read any text. - public init(codeLanguage: CodeLanguage, textProvider: @escaping ResolvingQueryCursor.TextProvider) throws { - parser = Parser() - languageQuery = TreeSitterModel.shared.query(for: codeLanguage.id) - tree = nil - + public init(codeLanguage: CodeLanguage, textProvider: @escaping ResolvingQueryCursor.TextProvider) { self.textProvider = textProvider + self.primaryLayer = codeLanguage.id + setLanguage(codeLanguage: codeLanguage) + } + + /// Sets the primary language for the client. Will reset all layers, will not do any parsing work. + /// - Parameter codeLanguage: The new primary language. + public func setLanguage(codeLanguage: CodeLanguage) { + // Remove all trees and languages, everything needs to be re-parsed. + layers.removeAll() + + primaryLayer = codeLanguage.id + layers = [ + LanguageLayer( + id: codeLanguage.id, + parser: Parser(), + supportsInjections: codeLanguage.additionalHighlights?.contains("injections") ?? false, + tree: nil, + languageQuery: TreeSitterModel.shared.query(for: codeLanguage.id), + ranges: [] + ) + ] if let treeSitterLanguage = codeLanguage.language { - try parser.setLanguage(treeSitterLanguage) + try? layers[0].parser.setLanguage(treeSitterLanguage) } } - func setLanguage(codeLanguage: CodeLanguage) { - if let treeSitterLanguage = codeLanguage.language { - try? parser.setLanguage(treeSitterLanguage) - } + // MARK: - HighlightProviding + + /// Set up and parse the initial language tree and all injected layers. + func setUp(textView: HighlighterTextView) { + let readBlock = createReadBlock(textView: textView) + + layers[0].tree = createTree( + parser: layers[0].parser, + readBlock: readBlock + ) + + var layerSet = Set(arrayLiteral: layers[0]) + var touchedLayers = Set() + + var idx = 0 + while idx < layers.count { + updateInjectedLanguageLayers( + textView: textView, + layer: layers[idx], + layerSet: &layerSet, + touchedLayers: &touchedLayers, + readBlock: readBlock + ) - // Get rid of the current tree, it needs to be re-parsed. - tree = nil + idx += 1 + } } /// Notifies the highlighter of an edit and in exchange gets a set of indices that need to be re-highlighted. @@ -67,151 +112,179 @@ final class TreeSitterClient: HighlightProviding { /// - range: The range of the edit. /// - delta: The length of the edit, can be negative for deletions. /// - completion: The function to call with an `IndexSet` containing all Indices to invalidate. - func applyEdit(textView: HighlighterTextView, - range: NSRange, - delta: Int, - completion: @escaping ((IndexSet) -> Void)) { - guard let edit = InputEdit(range: range, delta: delta, oldEndPoint: .zero) else { - return - } + func applyEdit( + textView: HighlighterTextView, + range: NSRange, + delta: Int, + completion: @escaping ((IndexSet) -> Void) + ) { + guard let edit = InputEdit(range: range, delta: delta, oldEndPoint: .zero) else { return } + let readBlock = createReadBlock(textView: textView) + var rangeSet = IndexSet() - let readFunction: Parser.ReadBlock = { byteOffset, _ in - let limit = textView.documentRange.length - let location = byteOffset / 2 - let end = min(location + (1024), limit) - if location > end { - assertionFailure("location is greater than end") - return nil + // Helper data structure for finding existing layers in O(1) when adding injected layers + var layerSet = Set(minimumCapacity: layers.count) + // Tracks which layers were not touched at some point during the edit. Any layers left in this set + // after the second loop are removed. + var touchedLayers = Set(minimumCapacity: layers.count) + + // Loop through all layers, apply edits & find changed byte ranges. + for layerIdx in (0.. Void)) { - // Make sure we dont accidentally change the tree while we copy it. - self.semaphore.wait() - guard let tree = self.tree?.copy() else { - // In this case, we don't have a tree to work with already, so we need to make it and try to - // return some highlights - createTree(textView: textView) - - // This is slightly redundant but we're only doing one check. - guard let treeRetry = self.tree?.copy() else { - // Now we can return nothing for real. - self.semaphore.signal() - completion([]) - return + // Loop again and apply injections query, add any ranges not previously found + // using while loop because `updateInjectedLanguageLayers` can add to `layers` during the loop + var idx = 0 + while idx < layers.count { + let layer = layers[idx] + + if layer.supportsInjections { + rangeSet.formUnion( + updateInjectedLanguageLayers( + textView: textView, + layer: layer, + layerSet: &layerSet, + touchedLayers: &touchedLayers, + readBlock: readBlock + ) + ) } - self.semaphore.signal() - _queryColorsFor(tree: treeRetry, range: range, completion: completion) - return + idx += 1 } - self.semaphore.signal() + // Delete any layers that weren't touched at some point during the edit. + layers.removeAll(where: { touchedLayers.contains($0) }) - _queryColorsFor(tree: tree, range: range, completion: completion) + completion(rangeSet) } - private func _queryColorsFor(tree: Tree, - range: NSRange, - completion: @escaping (([HighlightRange]) -> Void)) { - guard let rootNode = tree.rootNode else { - completion([]) - return + /// Initiates a highlight query. + /// - Parameters: + /// - textView: The text view to use. + /// - range: The range to limit the highlights to. + /// - completion: Called when the query completes. + func queryHighlightsFor( + textView: HighlighterTextView, + range: NSRange, + completion: @escaping ((([HighlightRange]) -> Void)) + ) { + var highlights: [HighlightRange] = [] + var injectedSet = IndexSet(integersIn: range) + + for layer in layers where layer.id != primaryLayer { + // Query injected only if a layer's ranges intersects with `range` + for layerRange in layer.ranges { + if let rangeIntersection = range.intersection(layerRange) { + highlights.append(contentsOf: queryLayerHighlights( + layer: layer, + textView: textView, + range: rangeIntersection + )) + + injectedSet.remove(integersIn: rangeIntersection) + } + } } - // This needs to be on the main thread since we're going to use the `textProvider` in - // the `highlightsFromCursor` method, which uses the textView's text storage. - guard let cursor = self.languageQuery?.execute(node: rootNode, in: tree) else { - completion([]) - return + // Query primary for any ranges that weren't used in the injected layers. + for range in injectedSet.rangeView { + highlights.append(contentsOf: queryLayerHighlights( + layer: layers[0], + textView: textView, + range: NSRange(range) + )) } - cursor.setRange(range) - - let highlights = self.highlightsFromCursor(cursor: ResolvingQueryCursor(cursor: cursor)) completion(highlights) } - /// Creates a tree. - /// - Parameter textView: The text provider to use. - private func createTree(textView: HighlighterTextView) { - self.tree = self.parser.parse(textView.stringForRange(textView.documentRange) ?? "") - } - - /// Resolves a query cursor to the highlight ranges it contains. - /// **Must be called on the main thread** - /// - Parameter cursor: The cursor to resolve. - /// - Returns: Any highlight ranges contained in the cursor. - private func highlightsFromCursor(cursor: ResolvingQueryCursor) -> [HighlightRange] { - cursor.prepare(with: self.textProvider) - return cursor - .flatMap { $0.captures } - .map { HighlightRange(range: $0.range, capture: CaptureName.fromString($0.name ?? "")) } - } -} + // MARK: - Helpers -extension TreeSitterClient { - /// Applies the edit to the current `tree` and returns the old tree and a copy of the current tree with the - /// processed edit. + /// Attempts to create a language layer and load a highlights file. + /// Adds the layer to the `layers` array if successful. /// - Parameters: - /// - edit: The edit to apply. - /// - readBlock: The block to use to read text. - /// - Returns: (The old state, the new state). - private func calculateNewState(edit: InputEdit, - readBlock: @escaping Parser.ReadBlock) -> (Tree?, Tree?) { - guard let oldTree = self.tree else { - return (nil, nil) + /// - layerId: A language ID to add as a layer. + /// - readBlock: Completion called for efficient string lookup. + internal func addLanguageLayer( + layerId: TreeSitterLanguage, + readBlock: @escaping Parser.ReadBlock + ) -> LanguageLayer? { + guard let language = CodeLanguage.allLanguages.first(where: { $0.id == layerId }), + let parserLanguage = language.language + else { + return nil } - self.semaphore.wait() - // Apply the edit to the old tree - oldTree.edit(edit) - - self.tree = self.parser.parse(tree: oldTree, readBlock: readBlock) - - self.semaphore.signal() + let newLayer = LanguageLayer( + id: layerId, + parser: Parser(), + supportsInjections: language.additionalHighlights?.contains("injections") ?? false, + tree: nil, + languageQuery: TreeSitterModel.shared.query(for: layerId), + ranges: [] + ) + + do { + try newLayer.parser.setLanguage(parserLanguage) + } catch { + return nil + } - return (oldTree.copy(), self.tree?.copy()) + layers.append(newLayer) + return newLayer } - /// Calculates the changed byte ranges between two trees. + /// Creates a tree-sitter tree. /// - Parameters: - /// - lhs: The first (older) tree. - /// - rhs: The second (newer) tree. - /// - Returns: Any changed ranges. - private func changedByteRanges(_ lhs: Tree?, rhs: Tree?) -> [Range] { - switch (lhs, rhs) { - case (let t1?, let t2?): - return t1.changedRanges(from: t2).map({ $0.bytes }) - case (nil, let t2?): - let range = t2.rootNode?.byteRange - - return range.flatMap({ [$0] }) ?? [] - case (_, nil): - return [] + /// - parser: The parser object to use to parse text. + /// - readBlock: A callback for fetching blocks of text. + /// - Returns: A tree if it could be parsed. + internal func createTree(parser: Parser, readBlock: @escaping Parser.ReadBlock) -> Tree? { + return parser.parse(tree: nil, readBlock: readBlock) + } + + internal func createReadBlock(textView: HighlighterTextView) -> Parser.ReadBlock { + return { byteOffset, _ in + let limit = textView.documentRange.length + let location = byteOffset / 2 + let end = min(location + (1024), limit) + if location > end { + // Ignore and return nothing, tree-sitter's internal tree can be incorrect in some situations. + return nil + } + let range = NSRange(location.. Date: Fri, 17 Feb 2023 14:34:03 -0600 Subject: [PATCH 13/17] Respect user indent settings --- .../CodeEditTextView/CodeEditTextView.swift | 20 ++++++--- .../Controller/STTextViewController.swift | 21 +++++++-- .../Filters/NewlineFilter.swift | 45 ------------------- .../STTextViewController+TextFormation.swift | 10 ++--- 4 files changed, 38 insertions(+), 58 deletions(-) delete mode 100644 Sources/CodeEditTextView/Filters/NewlineFilter.swift diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 9a80e4bad..e8f6f6bed 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -32,7 +32,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: CodeLanguage, theme: Binding, font: Binding, - tabWidth: Binding, + indentationWidth: Binding, + indentationUnit: Binding, lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), @@ -47,7 +48,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self._theme = theme self.useThemeBackground = useThemeBackground self._font = font - self._tabWidth = tabWidth + self._indentationWidth = indentationWidth + self._indentationUnit = indentationUnit self._lineHeight = lineHeight self._wrapLines = wrapLines self._editorOverscroll = editorOverscroll @@ -61,7 +63,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var language: CodeLanguage @Binding private var theme: EditorTheme @Binding private var font: NSFont - @Binding private var tabWidth: Int + @Binding private var indentationWidth: Int + @Binding private var indentationUnit: String @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @Binding private var editorOverscroll: Double @@ -79,7 +82,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: language, font: font, theme: theme, - tabWidth: tabWidth, + indentationWidth: indentationWidth, + indentationUnit: indentationUnit, wrapLines: wrapLines, cursorPosition: $cursorPosition, editorOverscroll: editorOverscroll, @@ -94,7 +98,6 @@ 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 @@ -109,6 +112,13 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.theme = theme } + // Updating the indentation width/unit causes regeneration of text filters. + if controller.indentationWidth != indentationWidth || + controller.indentationUnit != indentationUnit { + controller.indentationWidth = indentationWidth + controller.indentationUnit = indentationUnit + } + controller.reloadUI() return } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index cc7eeea53..8c10d9ee6 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -38,7 +38,20 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt public var useThemeBackground: Bool /// The number of spaces to use for a `tab '\t'` character - public var tabWidth: Int + /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. + public var indentationWidth: Int { + didSet { + setUpTextFormation() + } + } + + /// The string to use for tabs. Will by multiplied by `tabWidth` when inserted as an indent. + /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. + public var indentationUnit: String { + didSet { + setUpTextFormation() + } + } /// A multiplier for setting the line height. Defaults to `1.0` public var lineHeightMultiple: Double = 1.0 @@ -81,7 +94,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt language: CodeLanguage, font: NSFont, theme: EditorTheme, - tabWidth: Int, + indentationWidth: Int, + indentationUnit: String, wrapLines: Bool, cursorPosition: Binding<(Int, Int)>, editorOverscroll: Double, @@ -94,7 +108,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.language = language self.font = font self.theme = theme - self.tabWidth = tabWidth + self.indentationWidth = indentationWidth + self.indentationUnit = indentationUnit self.wrapLines = wrapLines self.cursorPosition = cursorPosition self.editorOverscroll = editorOverscroll diff --git a/Sources/CodeEditTextView/Filters/NewlineFilter.swift b/Sources/CodeEditTextView/Filters/NewlineFilter.swift deleted file mode 100644 index b9ac913dc..000000000 --- a/Sources/CodeEditTextView/Filters/NewlineFilter.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// NewlineFilter.swift -// -// -// Created by Khan Winter on 1/28/23. -// - -import Foundation -import TextFormation -import TextStory - -/// A newline filter almost entirely similar to `TextFormation`s standard implementation. -struct NewlineFilter: Filter { - private let recognizer: ConsecutiveCharacterRecognizer - let providers: WhitespaceProviders - - init(whitespaceProviders: WhitespaceProviders) { - self.recognizer = ConsecutiveCharacterRecognizer(matching: "\n") - self.providers = whitespaceProviders - } - - func processMutation(_ mutation: TextStory.TextMutation, - in interface: TextFormation.TextInterface) -> TextFormation.FilterAction { - recognizer.processMutation(mutation) - - switch recognizer.state { - case .triggered: - return filterHandler(mutation, in: interface) - case .tracking, .idle: - return .none - } - } - - private func filterHandler(_ mutation: TextMutation, in interface: TextInterface) -> FilterAction { - interface.applyMutation(mutation) - - let range = NSRange(location: mutation.postApplyRange.max, length: 0) - - let value = providers.leadingWhitespace(range, interface) - - interface.insertString(value, at: mutation.postApplyRange.max) - - return .discard - } -} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 0e0ad7cac..49efbb67b 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 indent = String(repeating: self.indentationUnit, count: indentationWidth) let pairsToHandle: [(String, String)] = [ ("{", "}"), @@ -29,8 +29,8 @@ extension STTextViewController { let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( - leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, - width: tabWidth), + leadingWhitespace: indenter.substitionProvider(indentationUnit: indent, + width: indentationWidth), trailingWhitespace: { _, _ in "" } ) @@ -38,9 +38,9 @@ extension STTextViewController { setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indentationUnit) + indentationUnit: indent) setUpDeletePairFilters(pairs: pairsToHandle) - setUpDeleteWhitespaceFilter(indentationUnit: indentationUnit) + setUpDeleteWhitespaceFilter(indentationUnit: indent) } /// Returns a `TextualIndenter` based on available language configuration. From 138409ed7aadc682f2dcefcf388fa9b2c0027e19 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:45:08 -0600 Subject: [PATCH 14/17] Distinguish between indent unit and tab width --- .../CodeEditTextView/CodeEditTextView.swift | 20 +++++++++++-------- .../Controller/STTextViewController.swift | 15 ++++++-------- .../STTextViewController+TextFormation.swift | 10 ++++------ 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index e8f6f6bed..c9f772153 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -19,6 +19,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { /// - theme: The theme for syntax highlighting /// - font: The default font /// - tabWidth: The tab width + /// - indentationUnit: The string to use for indents. /// - 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`) @@ -32,7 +33,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: CodeLanguage, theme: Binding, font: Binding, - indentationWidth: Binding, + tabWidth: Binding, indentationUnit: Binding, lineHeight: Binding, wrapLines: Binding, @@ -48,7 +49,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self._theme = theme self.useThemeBackground = useThemeBackground self._font = font - self._indentationWidth = indentationWidth + self._tabWidth = tabWidth self._indentationUnit = indentationUnit self._lineHeight = lineHeight self._wrapLines = wrapLines @@ -63,7 +64,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var language: CodeLanguage @Binding private var theme: EditorTheme @Binding private var font: NSFont - @Binding private var indentationWidth: Int + @Binding private var tabWidth: Int @Binding private var indentationUnit: String @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @@ -82,7 +83,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { language: language, font: font, theme: theme, - indentationWidth: indentationWidth, + tabWidth: tabWidth, indentationUnit: indentationUnit, wrapLines: wrapLines, cursorPosition: $cursorPosition, @@ -112,10 +113,13 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.theme = theme } - // Updating the indentation width/unit causes regeneration of text filters. - if controller.indentationWidth != indentationWidth || - controller.indentationUnit != indentationUnit { - controller.indentationWidth = indentationWidth + // Updating the tab width (will) reset the default paragraph style, needing a re-render. + if controller.tabWidth != tabWidth { + controller.tabWidth = tabWidth + } + + // Updating the indentation unit causes regeneration of text filters. + if controller.indentationUnit != indentationUnit { controller.indentationUnit = indentationUnit } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 8c10d9ee6..5dddb9e12 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -37,13 +37,8 @@ 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 - /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. - public var indentationWidth: Int { - didSet { - setUpTextFormation() - } - } + /// The number of visual spaces to use for a `tab '\t'` character + public var tabWidth: Int /// The string to use for tabs. Will by multiplied by `tabWidth` when inserted as an indent. /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. @@ -94,7 +89,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt language: CodeLanguage, font: NSFont, theme: EditorTheme, - indentationWidth: Int, + tabWidth: Int, indentationUnit: String, wrapLines: Bool, cursorPosition: Binding<(Int, Int)>, @@ -108,7 +103,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.language = language self.font = font self.theme = theme - self.indentationWidth = indentationWidth + self.tabWidth = tabWidth self.indentationUnit = indentationUnit self.wrapLines = wrapLines self.cursorPosition = cursorPosition @@ -234,6 +229,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight +// paragraph.tabStops.removeAll() +// paragraph.defaultTabInterval = CGFloat(tabWidth) * " ".size(withAttributes: [.font: self.font]).width return paragraph } diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 49efbb67b..052b6316d 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -18,8 +18,6 @@ extension STTextViewController { internal func setUpTextFormation() { textFilters = [] - let indent = String(repeating: self.indentationUnit, count: indentationWidth) - let pairsToHandle: [(String, String)] = [ ("{", "}"), ("[", "]"), @@ -29,8 +27,8 @@ extension STTextViewController { let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( - leadingWhitespace: indenter.substitionProvider(indentationUnit: indent, - width: indentationWidth), + leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, + width: indentationUnit.count), trailingWhitespace: { _, _ in "" } ) @@ -38,9 +36,9 @@ extension STTextViewController { setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indent) + indentationUnit: indentationUnit) setUpDeletePairFilters(pairs: pairsToHandle) - setUpDeleteWhitespaceFilter(indentationUnit: indent) + setUpDeleteWhitespaceFilter(indentationUnit: indentationUnit) } /// Returns a `TextualIndenter` based on available language configuration. From 87bba3f2ad9aa2b1a966f06a543f6cd012694777 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 19 Feb 2023 13:56:24 -0600 Subject: [PATCH 15/17] Add defaults to initializers --- Sources/CodeEditTextView/CodeEditTextView.swift | 2 +- Sources/CodeEditTextView/Controller/STTextViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index c9f772153..facdcdc81 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -34,7 +34,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { theme: Binding, font: Binding, tabWidth: Binding, - indentationUnit: Binding, + indentationUnit: Binding = .constant(String(repeating: " ", count: 4)), lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 5dddb9e12..67aa0fa6a 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -90,7 +90,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt font: NSFont, theme: EditorTheme, tabWidth: Int, - indentationUnit: String, + indentationUnit: String = String(repeating: " ", count: 4), wrapLines: Bool, cursorPosition: Binding<(Int, Int)>, editorOverscroll: Double, From ad8908edca9bbf45e6bab3091ffd0427a43ed3ec Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 26 Mar 2023 11:29:58 -0500 Subject: [PATCH 16/17] Use `IndentOption` and clarify usage --- .../CodeEditTextView/CodeEditTextView.swift | 16 +++---- .../Controller/STTextViewController.swift | 10 ++--- .../CodeEditTextView/Enums/IndentOption.swift | 45 +++++++++++++++++++ .../STTextViewController+TextFormation.swift | 8 ++-- .../STTextViewControllerTests.swift | 1 + 5 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 Sources/CodeEditTextView/Enums/IndentOption.swift diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index facdcdc81..7fe2f4268 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -18,8 +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 - /// - indentationUnit: The string to use for indents. + /// - tabWidth: The visual tab width in number of spaces + /// - indentOption: The option to use for indentation 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`) @@ -34,7 +34,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { theme: Binding, font: Binding, tabWidth: Binding, - indentationUnit: Binding = .constant(String(repeating: " ", count: 4)), + indentOption: Binding = .constant(.string(count: 4)), lineHeight: Binding, wrapLines: Binding, editorOverscroll: Binding = .constant(0.0), @@ -50,7 +50,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.useThemeBackground = useThemeBackground self._font = font self._tabWidth = tabWidth - self._indentationUnit = indentationUnit + self._indentOption = indentOption self._lineHeight = lineHeight self._wrapLines = wrapLines self._editorOverscroll = editorOverscroll @@ -65,7 +65,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { @Binding private var theme: EditorTheme @Binding private var font: NSFont @Binding private var tabWidth: Int - @Binding private var indentationUnit: String + @Binding private var indentOption: IndentOption @Binding private var lineHeight: Double @Binding private var wrapLines: Bool @Binding private var editorOverscroll: Double @@ -84,7 +84,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { font: font, theme: theme, tabWidth: tabWidth, - indentationUnit: indentationUnit, + indentOption: indentOption, wrapLines: wrapLines, cursorPosition: $cursorPosition, editorOverscroll: editorOverscroll, @@ -119,8 +119,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { } // Updating the indentation unit causes regeneration of text filters. - if controller.indentationUnit != indentationUnit { - controller.indentationUnit = indentationUnit + if controller.indentOption != indentOption { + controller.indentOption = indentOption } controller.reloadUI() diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 67aa0fa6a..50811b6dc 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -37,12 +37,12 @@ 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 visual spaces to use for a `tab '\t'` character + /// The number of visual spaces to use for a tab `'\t'` character public var tabWidth: Int - /// The string to use for tabs. Will by multiplied by `tabWidth` when inserted as an indent. + /// The configuration to use when the tab key is pressed. /// - Note: When set, text filters will be re-generated. Try to avoid setting this parameter needlessly. - public var indentationUnit: String { + public var indentOption: IndentOption { didSet { setUpTextFormation() } @@ -90,7 +90,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt font: NSFont, theme: EditorTheme, tabWidth: Int, - indentationUnit: String = String(repeating: " ", count: 4), + indentOption: IndentOption, wrapLines: Bool, cursorPosition: Binding<(Int, Int)>, editorOverscroll: Double, @@ -104,7 +104,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.font = font self.theme = theme self.tabWidth = tabWidth - self.indentationUnit = indentationUnit + self.indentOption = indentOption self.wrapLines = wrapLines self.cursorPosition = cursorPosition self.editorOverscroll = editorOverscroll diff --git a/Sources/CodeEditTextView/Enums/IndentOption.swift b/Sources/CodeEditTextView/Enums/IndentOption.swift new file mode 100644 index 000000000..f17eb4012 --- /dev/null +++ b/Sources/CodeEditTextView/Enums/IndentOption.swift @@ -0,0 +1,45 @@ +// +// IndentOption.swift +// +// +// Created by Khan Winter on 3/26/23. +// + +/// Represents what to insert on a tab key press. +/// +/// Conforms to `Codable` with a JSON structure like below: +/// ```json +/// { +/// "string": { +/// "count": Int +/// } +/// } +/// ``` +/// or +/// ```json +/// { "tab": { } } +/// ``` +public enum IndentOption: Equatable, Codable { + case string(count: Int) + case tab + + var stringValue: String { + switch self { + case .string(let count): + return String(repeating: " ", count: count) + case .tab: + return "\t" + } + } + + public static func == (lhs: IndentOption, rhs: IndentOption) -> Bool { + switch (lhs, rhs) { + case (.tab, .tab): + return true + case (.string(let lhsCount), .string(let rhsCount)): + return lhsCount == rhsCount + default: + return false + } + } +} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 052b6316d..cdabd6476 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -27,8 +27,8 @@ extension STTextViewController { let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( - leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, - width: indentationUnit.count), + leadingWhitespace: indenter.substitionProvider(indentationUnit: indentOption.stringValue, + width: indentOption.stringValue.count), trailingWhitespace: { _, _ in "" } ) @@ -36,9 +36,9 @@ extension STTextViewController { setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indentationUnit) + indentationUnit: indentOption.stringValue) setUpDeletePairFilters(pairs: pairsToHandle) - setUpDeleteWhitespaceFilter(indentationUnit: indentationUnit) + setUpDeleteWhitespaceFilter(indentationUnit: indentOption.stringValue) } /// Returns a `TextualIndenter` based on available language configuration. diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index c90d94e34..6398dbd2f 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -33,6 +33,7 @@ final class STTextViewControllerTests: XCTestCase { font: .monospacedSystemFont(ofSize: 11, weight: .medium), theme: theme, tabWidth: 4, + indentOption: .string(count: 4), wrapLines: true, cursorPosition: .constant((1, 1)), editorOverscroll: 0.5, From 6dc5b5f8d120d33130e57d943a7679f2d124b6eb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 26 Mar 2023 11:53:11 -0500 Subject: [PATCH 17/17] Remove tab replacement filter --- .../STTextViewController+TextFormation.swift | 12 +++------ .../Filters/TabReplacementFilter.swift | 27 ------------------- 2 files changed, 4 insertions(+), 35 deletions(-) delete mode 100644 Sources/CodeEditTextView/Filters/TabReplacementFilter.swift diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index cdabd6476..1e84e810e 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -35,8 +35,7 @@ extension STTextViewController { // Filters setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) - setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, - indentationUnit: indentOption.stringValue) + setUpNewlineFilter(whitespaceProvider: whitespaceProvider) setUpDeletePairFilters(pairs: pairsToHandle) setUpDeleteWhitespaceFilter(indentationUnit: indentOption.stringValue) } @@ -65,14 +64,11 @@ extension STTextViewController { } /// Configures newline and tab replacement filters. - /// - Parameters: - /// - whitespaceProvider: The whitespace providers to use. - /// - indentationUnit: The unit of indentation to use. - private func setUpNewlineTabFilters(whitespaceProvider: WhitespaceProviders, indentationUnit: String) { + /// - Parameter whitespaceProvider: The whitespace providers to use. + private func setUpNewlineFilter(whitespaceProvider: WhitespaceProviders) { let newlineFilter: Filter = NewlineProcessingFilter(whitespaceProviders: whitespaceProvider) - let tabReplacementFilter: Filter = TabReplacementFilter(indentationUnit: indentationUnit) - textFilters.append(contentsOf: [newlineFilter, tabReplacementFilter]) + textFilters.append(contentsOf: [newlineFilter]) } /// Configures delete pair filters. diff --git a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift b/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift deleted file mode 100644 index c8db1f4b7..000000000 --- a/Sources/CodeEditTextView/Filters/TabReplacementFilter.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// TabReplacementFilter.swift -// -// -// Created by Khan Winter on 1/28/23. -// - -import Foundation -import TextFormation -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 - - func processMutation(_ mutation: TextMutation, in interface: TextInterface) -> FilterAction { - if mutation.string == "\t" { - interface.applyMutation(TextMutation(insert: indentationUnit, - at: mutation.range.location, - limit: mutation.limit)) - return .discard - } else { - return .none - } - } -}