From 8bd6a0b1e96af9461244db9ba7b4893f4e20ea07 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:59:47 -0500 Subject: [PATCH 1/6] Draw Invisible Characters Adds a configuration option to draw invisible characters. --- .../Views/ContentView.swift | 7 +- .../Views/StatusBar.swift | 28 +++++ Package.resolved | 13 +-- .../CodeEditSourceEditor.swift | 19 +++- .../Controller/TextViewController.swift | 28 ++++- .../InvisibleCharactersConfig.swift | 63 +++++++++++ .../InvisibleCharactersCoordinator.swift | 103 ++++++++++++++++++ .../Minimap/MinimapLineFragmentView.swift | 4 +- 8 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift create mode 100644 Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 4c896888b..583adf29f 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -30,6 +30,7 @@ struct ContentView: View { @State private var indentOption: IndentOption = .spaces(count: 4) @AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80 @AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false + @State private var invisibleCharactersConfig: InvisibleCharactersConfig = .empty init(document: Binding, fileURL: URL?) { self._document = document @@ -56,7 +57,8 @@ struct ContentView: View { useSystemCursor: useSystemCursor, showMinimap: showMinimap, reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + invisibleCharactersConfig: invisibleCharactersConfig ) .overlay(alignment: .bottom) { StatusBar( @@ -71,7 +73,8 @@ struct ContentView: View { showMinimap: $showMinimap, indentOption: $indentOption, reformatAtColumn: $reformatAtColumn, - showReformattingGuide: $showReformattingGuide + showReformattingGuide: $showReformattingGuide, + invisibles: $invisibleCharactersConfig ) } .ignoresSafeArea() diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index d471706f9..4698d0a17 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -26,6 +26,7 @@ struct StatusBar: View { @Binding var indentOption: IndentOption @Binding var reformatAtColumn: Int @Binding var showReformattingGuide: Bool + @Binding var invisibles: InvisibleCharactersConfig var body: some View { HStack { @@ -50,6 +51,33 @@ struct StatusBar: View { .disabled(true) .help("macOS 14 required") } + + Menu { + Toggle("Spaces", isOn: $invisibles.showSpaces) + Toggle("Tabs", isOn: $invisibles.showTabs) + Toggle("Line Endings", isOn: $invisibles.showLineEndings) + Divider() + Toggle( + "Warning Characters", + isOn: Binding( + get: { + !invisibles.warningCharacters.isEmpty + }, + set: { newValue in + // In this example app, we only add one character + // For real apps, consider providing a table where users can add UTF16 + // char codes to warn about, as well as a set of good defaults. + if newValue { + invisibles.warningCharacters.insert(0x200B) // zero-width space + } else { + invisibles.warningCharacters.removeAll() + } + } + ) + ) + } label: { + Text("Invisibles") + } } label: {} .background { Image(systemName: "switch.2") diff --git a/Package.resolved b/Package.resolved index 7296d78dd..edf20858b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", "state" : { - "revision" : "331d5dbc5fc8513be5848fce8a2a312908f36a11", - "version" : "0.1.20" + "revision" : "32a1ddcb44d0bcf9bdb41ae426fa1bb7198e62fe", + "version" : "0.1.21" } }, { @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 12a0f5a10..0e9a88dd6 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -53,6 +53,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - showMinimap: Whether to show the minimap /// - reformatAtColumn: The column to reformat at /// - showReformattingGuide: Whether to show the reformatting guide + /// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object. public init( _ text: Binding, language: CodeLanguage, @@ -77,7 +78,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + invisibleCharactersConfig: InvisibleCharactersConfig = .empty ) { self.text = .binding(text) self.language = language @@ -107,6 +109,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.invisibleCharactersConfig = invisibleCharactersConfig } /// Initializes a Text Editor @@ -139,6 +142,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - showMinimap: Whether to show the minimap /// - reformatAtColumn: The column to reformat at /// - showReformattingGuide: Whether to show the reformatting guide + /// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object. public init( _ text: NSTextStorage, language: CodeLanguage, @@ -163,7 +167,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + invisibleCharactersConfig: InvisibleCharactersConfig = .empty ) { self.text = .storage(text) self.language = language @@ -193,6 +198,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.invisibleCharactersConfig = invisibleCharactersConfig } package var text: TextAPI @@ -219,6 +225,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { package var showMinimap: Bool private var reformatAtColumn: Int private var showReformattingGuide: Bool + private var invisibleCharactersConfig: InvisibleCharactersConfig public typealias NSViewControllerType = TextViewController @@ -247,7 +254,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: coordinators, showMinimap: showMinimap, reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + invisibleCharactersConfig: invisibleCharactersConfig ) switch text { case .binding(let binding): @@ -352,6 +360,10 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if controller.useSystemCursor != useSystemCursor { controller.useSystemCursor = useSystemCursor } + + if controller.invisibleCharactersConfig != invisibleCharactersConfig { + controller.invisibleCharactersConfig = invisibleCharactersConfig + } } private func updateThemeAndLanguage(_ controller: TextViewController) { @@ -397,6 +409,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.showMinimap == showMinimap && controller.reformatAtColumn == reformatAtColumn && controller.showReformattingGuide == showReformattingGuide && + controller.invisibleCharactersConfig == invisibleCharactersConfig && areHighlightProvidersEqual(controller: controller, coordinator: coordinator) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 8d3b8b69f..5e1f63377 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -35,6 +35,10 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty var localEvenMonitor: Any? var isPostingCursorNotification: Bool = false + /// Middleman between the text view to our invisible characters config, with knowledge of things like the + /// user's theme and indent option to help correctly draw invisible character placeholders. + var invisibleCharactersCoordinator: InvisibleCharactersCoordinator + /// The string contents. public var string: String { textView.string @@ -52,6 +56,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty public var font: NSFont { didSet { textView.font = font + invisibleCharactersCoordinator.font = font highlighter?.invalidate() } } @@ -70,6 +75,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty gutterView.selectedLineTextColor = theme.text.color minimapView.setTheme(theme) guideView?.setTheme(theme) + invisibleCharactersCoordinator.theme = theme } } @@ -86,6 +92,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty public var indentOption: IndentOption { didSet { setUpTextFormation() + invisibleCharactersCoordinator.indentOption = indentOption } } @@ -265,6 +272,15 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } + /// Configuration for drawing invisible characters. + /// + /// See ``InvisibleCharactersConfig`` for more details. + var invisibleCharactersConfig: InvisibleCharactersConfig { + didSet { + invisibleCharactersCoordinator.config = invisibleCharactersConfig + } + } + // MARK: Init init( @@ -291,7 +307,8 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty coordinators: [TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int = 80, - showReformattingGuide: Bool = false + showReformattingGuide: Bool = false, + invisibleCharactersConfig: InvisibleCharactersConfig = .empty ) { self.language = language self.font = font @@ -314,6 +331,13 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator( + config: invisibleCharactersConfig, + indentOption: indentOption, + theme: theme, + font: font + ) + self.invisibleCharactersConfig = invisibleCharactersConfig super.init(nibName: nil, bundle: nil) @@ -342,6 +366,8 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty delegate: self ) + textView.layoutManager.invisibleCharacterDelegate = invisibleCharactersCoordinator + // Initialize guide view self.guideView = ReformattingGuideView(column: reformatAtColumn, isVisible: showReformattingGuide, theme: theme) diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift new file mode 100644 index 000000000..57ab37265 --- /dev/null +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift @@ -0,0 +1,63 @@ +// +// InvisibleCharactersConfig.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/11/25. +// + +/// Configuration for how the editor draws invisible characters. +public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable { + /// An empty configuration. + public static var empty: InvisibleCharactersConfig { + InvisibleCharactersConfig(showSpaces: false, showTabs: false, showLineEndings: false, warningCharacters: []) + } + + /// Set to true to draw spaces with a dot. + public var showSpaces: Bool + /// Set to true to draw tabs with a small arrow. + public var showTabs: Bool + /// Set to true to draw line endings. + public var showLineEndings: Bool + /// A set of characters the editor should draw with a small red border. + /// + /// Indicates characters that the user may not have meant to insert, such as a zero-width space: `(0x200D)` or a + /// non-standard quote character: `“ (0x201C)`. + public var warningCharacters: Set + + public init(showSpaces: Bool, showTabs: Bool, showLineEndings: Bool, warningCharacters: Set) { + self.showSpaces = showSpaces + self.showTabs = showTabs + self.showLineEndings = showLineEndings + self.warningCharacters = warningCharacters + } + + /// Determines what characters should trigger a custom drawing action. + func triggerCharacters() -> Set { + var set = Set() + + if showSpaces { + set.insert(Symbols.space) + } + + if showTabs { + set.insert(Symbols.tab) + } + + if showLineEndings { + set.insert(Symbols.lineFeed) + set.insert(Symbols.carriageReturn) + } + + set.formUnion(warningCharacters) + + return set + } + + /// Some commonly used whitespace symbols in their unichar representation. + public enum Symbols { + public static let space: UInt16 = 0x20 + public static let tab: UInt16 = 0x9 + public static let lineFeed: UInt16 = 0xA // \n + public static let carriageReturn: UInt16 = 0xD // \r + } +} diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift new file mode 100644 index 000000000..5652b28ea --- /dev/null +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -0,0 +1,103 @@ +// +// InvisibleCharactersCoordinator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/9/25. +// + +import AppKit +import CodeEditTextView + +/// Object that tells the text view how to draw invisible characters. +/// +/// Takes a few parameters for contextual drawing such as the current editor theme, font, and indent option. +/// +/// To keep lookups fast, does not use a computed property for ``InvisibleCharactersConfig/triggerCharacters``. +/// Instead, this type keeps that internal property up-to-date whenever config is updated. +/// +/// Another performance optimization is a cache mechanism in CodeEditTextView. Whenever the config, indent option, +/// theme, or font are updated, this object will tell the text view to clear it's cache. Keep updates to a minimum to +/// retain as much cached data as possible. +final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { + var config: InvisibleCharactersConfig { + didSet { + triggerCharacters = config.triggerCharacters() + } + } + var indentOption: IndentOption + var theme: EditorTheme { + didSet { + invisibleColor = theme.invisibles.color + needsCacheClear = true + } + } + var font: NSFont { + didSet { + emphasizedFont = NSFontManager.shared.font( + withFamily: font.familyName ?? "", + traits: .unboldFontMask, + weight: 15, // Condensed + size: font.pointSize + ) ?? font + needsCacheClear = true + } + } + + private var needsCacheClear = false + private var invisibleColor: NSColor + private var emphasizedFont: NSFont + + /// The set of characters the text view should trigger a call to ``invisibleStyle`` for. + var triggerCharacters: Set + + init(config: InvisibleCharactersConfig, indentOption: IndentOption, theme: EditorTheme, font: NSFont) { + self.config = config + self.indentOption = indentOption + self.theme = theme + self.font = font + triggerCharacters = config.triggerCharacters() + invisibleColor = theme.invisibles.color + emphasizedFont = NSFontManager.shared.font( + withFamily: font.familyName ?? "", + traits: .unboldFontMask, + weight: 15, // Condensed + size: font.pointSize + ) ?? font + } + + /// Determines if the textview should clear cached styles. + func invisibleStyleShouldClearCache() -> Bool { + if needsCacheClear { + needsCacheClear = false + return true + } + return false + } + + /// Determines the replacement style for a character found in a line fragment. Returns the style the text view + /// should use to emphasize or replace the character. + /// + /// Input is a unichar character (UInt16), and is compared to known characters. This method also emphasizes spaces + /// that appear on the same column user's selected indent width. The required font is expensive to compute + /// often and is cached in ``emphasizedFont``. + func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? { + switch character { + case InvisibleCharactersConfig.Symbols.space: + guard config.showSpaces else { return nil } + let locationInLine = range.location - lineRange.location + let shouldBold = locationInLine % indentOption.charCount == indentOption.charCount - 1 + return .replace(replacementCharacter: "·", color: invisibleColor, font: shouldBold ? emphasizedFont : font) + case InvisibleCharactersConfig.Symbols.tab: + guard config.showTabs else { return nil } + return .replace(replacementCharacter: "→", color: invisibleColor, font: font) + case InvisibleCharactersConfig.Symbols.carriageReturn, InvisibleCharactersConfig.Symbols.lineFeed: + guard config.showLineEndings else { return nil } + return .replace(replacementCharacter: "¬", color: invisibleColor, font: font) + default: + guard config.warningCharacters.contains(character) else { + return nil + } + return .emphasize(color: .systemRed.withAlphaComponent(0.3)) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift index bab62283b..02ac3c2bc 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -43,8 +43,8 @@ final class MinimapLineFragmentView: LineFragmentView { /// Set the new line fragment, and calculate drawing runs for drawing the fragment in the view. /// - Parameter newFragment: The new fragment to use. - override func setLineFragment(_ newFragment: LineFragment) { - super.setLineFragment(newFragment) + override func setLineFragment(_ newFragment: LineFragment, renderer: LineFragmentRenderer) { + super.setLineFragment(newFragment, renderer: renderer) guard let textStorage else { return } // Create the drawing runs using attribute information From 6b951255b94c1d59fc4eb2bfcac6c082c93fca1b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:52:38 -0500 Subject: [PATCH 2/6] lint:fix --- Package.resolved | 13 +++++++++++-- .../CodeEditSourceEditor.swift | 2 +- .../Controller/TextViewController.swift | 17 ++++++----------- .../InvisibleCharactersConfig.swift | 4 ++-- .../InvisibleCharactersCoordinator.swift | 4 ++-- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Package.resolved b/Package.resolved index edf20858b..7296d78dd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", "state" : { - "revision" : "32a1ddcb44d0bcf9bdb41ae426fa1bb7198e62fe", - "version" : "0.1.21" + "revision" : "331d5dbc5fc8513be5848fce8a2a312908f36a11", + "version" : "0.1.20" } }, { @@ -18,6 +18,15 @@ "version" : "0.2.3" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", + "version" : "0.11.1" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 0e9a88dd6..05b40591c 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -53,7 +53,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - showMinimap: Whether to show the minimap /// - reformatAtColumn: The column to reformat at /// - showReformattingGuide: Whether to show the reformatting guide - /// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object. + /// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object. public init( _ text: Binding, language: CodeLanguage, diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 5e1f63377..a500dbfc2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -331,21 +331,16 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide - self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator( - config: invisibleCharactersConfig, - indentOption: indentOption, - theme: theme, - font: font - ) + + invisibleCharactersCoordinator = .init(config: .empty, indentOption: indentOption, theme: theme, font: font) self.invisibleCharactersConfig = invisibleCharactersConfig super.init(nibName: nil, bundle: nil) - let platformGuardedSystemCursor: Bool - if #available(macOS 14, *) { - platformGuardedSystemCursor = useSystemCursor + let platformGuardedSystemCursor: Bool = if #available(macOS 14, *) { + useSystemCursor } else { - platformGuardedSystemCursor = false + false } if let idx = highlightProviders.firstIndex(where: { $0 is TreeSitterClient }), @@ -417,4 +412,4 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } localEvenMonitor = nil } -} +} // swiftlint:disable:this file_length diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift index 57ab37265..c0bcf569a 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift @@ -30,7 +30,7 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable self.showLineEndings = showLineEndings self.warningCharacters = warningCharacters } - + /// Determines what characters should trigger a custom drawing action. func triggerCharacters() -> Set { var set = Set() @@ -52,7 +52,7 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable return set } - + /// Some commonly used whitespace symbols in their unichar representation. public enum Symbols { public static let space: UInt16 = 0x20 diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift index 5652b28ea..2de455b20 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -64,7 +64,7 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { size: font.pointSize ) ?? font } - + /// Determines if the textview should clear cached styles. func invisibleStyleShouldClearCache() -> Bool { if needsCacheClear { @@ -73,7 +73,7 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { } return false } - + /// Determines the replacement style for a character found in a line fragment. Returns the style the text view /// should use to emphasize or replace the character. /// From 75fbf0562456748a9927e1fe0d9af7f413a76177 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 13 Jun 2025 11:03:55 -0500 Subject: [PATCH 3/6] Bump CETV - `v0.11.2` --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 7296d78dd..39dbd018e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" + "revision" : "c045ffcf4d8b2904e7bd138cdeccda99cba3ab3c", + "version" : "0.11.2" } }, { diff --git a/Package.swift b/Package.swift index 69556c288..0335428f1 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.1" + from: "0.11.2" ), // tree-sitter languages .package( From 01ec24614b4d37c9d61e41903128038344cb91cc Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:41:33 -0500 Subject: [PATCH 4/6] Fix Initial Value Bug --- .../Controller/TextViewController.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index a500dbfc2..33e332419 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -276,14 +276,20 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty /// /// See ``InvisibleCharactersConfig`` for more details. var invisibleCharactersConfig: InvisibleCharactersConfig { - didSet { - invisibleCharactersCoordinator.config = invisibleCharactersConfig + get { + invisibleCharactersCoordinator.config + } + set { + invisibleCharactersCoordinator.config = newValue } } // MARK: Init - init( + // Disabling function body length warning for now. There's an open issue for combining a lot of these parameters + // into a single config object. + + init( // swiftlint:disable:this function_body_length string: String, language: CodeLanguage, font: NSFont, @@ -331,9 +337,12 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide - - invisibleCharactersCoordinator = .init(config: .empty, indentOption: indentOption, theme: theme, font: font) - self.invisibleCharactersConfig = invisibleCharactersConfig + self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator( + config: invisibleCharactersConfig, + indentOption: indentOption, + theme: theme, + font: font + ) super.init(nibName: nil, bundle: nil) From b36b9bdf49d04263d1f26ceded05dbf04cb8bb13 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 16 Jun 2025 09:51:23 -0500 Subject: [PATCH 5/6] Add Replacement Strings Configuration --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../InvisibleCharactersConfig.swift | 30 +++++++- .../InvisibleCharactersCoordinator.swift | 73 +++++++++++++++---- 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 243527a2a..ed390550e 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" + "revision" : "c045ffcf4d8b2904e7bd138cdeccda99cba3ab3c", + "version" : "0.11.2" } }, { diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift index c0bcf569a..74acf9bfb 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift @@ -6,6 +6,9 @@ // /// Configuration for how the editor draws invisible characters. +/// +/// Enable specific categories using the ``showSpaces``, ``showTabs``, and ``showLineEndings`` toggles. Customize +/// drawing further with the ``spaceReplacement`` and family variables. public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable { /// An empty configuration. public static var empty: InvisibleCharactersConfig { @@ -14,17 +17,38 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable /// Set to true to draw spaces with a dot. public var showSpaces: Bool + /// Set to true to draw tabs with a small arrow. public var showTabs: Bool + /// Set to true to draw line endings. public var showLineEndings: Bool + + /// Replacement when drawing the space character, enabled by ``showSpaces``. + public var spaceReplacement: String = "·" + /// Replacement when drawing the tab character, enabled by ``showTabs``. + public var tabReplacement: String = "→" + /// Replacement when drawing the carriage return character, enabled by ``showLineEndings``. + public var carriageReturnReplacement: String = "↵" + /// Replacement when drawing the line feed character, enabled by ``showLineEndings``. + public var lineFeedReplacement: String = "¬" + /// Replacement when drawing the paragraph separator character, enabled by ``showLineEndings``. + public var paragraphSeparatorReplacement: String = "¶" + /// Replacement when drawing the line separator character, enabled by ``showLineEndings``. + public var lineSeparatorReplacement: String = "⏎" + /// A set of characters the editor should draw with a small red border. /// /// Indicates characters that the user may not have meant to insert, such as a zero-width space: `(0x200D)` or a /// non-standard quote character: `“ (0x201C)`. public var warningCharacters: Set - public init(showSpaces: Bool, showTabs: Bool, showLineEndings: Bool, warningCharacters: Set) { + public init( + showSpaces: Bool, + showTabs: Bool, + showLineEndings: Bool, + warningCharacters: Set + ) { self.showSpaces = showSpaces self.showTabs = showTabs self.showLineEndings = showLineEndings @@ -46,6 +70,8 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable if showLineEndings { set.insert(Symbols.lineFeed) set.insert(Symbols.carriageReturn) + set.insert(Symbols.paragraphSeparator) + set.insert(Symbols.lineSeparator) } set.formUnion(warningCharacters) @@ -59,5 +85,7 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable public static let tab: UInt16 = 0x9 public static let lineFeed: UInt16 = 0xA // \n public static let carriageReturn: UInt16 = 0xD // \r + public static let paragraphSeparator: UInt16 = 0x2029 // ¶ + public static let lineSeparator: UInt16 = 0x2028 // line separator } } diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift index 2de455b20..da718278d 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -83,21 +83,68 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? { switch character { case InvisibleCharactersConfig.Symbols.space: - guard config.showSpaces else { return nil } - let locationInLine = range.location - lineRange.location - let shouldBold = locationInLine % indentOption.charCount == indentOption.charCount - 1 - return .replace(replacementCharacter: "·", color: invisibleColor, font: shouldBold ? emphasizedFont : font) + return spacesStyle(range: range, lineRange: lineRange) case InvisibleCharactersConfig.Symbols.tab: - guard config.showTabs else { return nil } - return .replace(replacementCharacter: "→", color: invisibleColor, font: font) - case InvisibleCharactersConfig.Symbols.carriageReturn, InvisibleCharactersConfig.Symbols.lineFeed: - guard config.showLineEndings else { return nil } - return .replace(replacementCharacter: "¬", color: invisibleColor, font: font) + return tabStyle() + case InvisibleCharactersConfig.Symbols.carriageReturn: + return carriageReturnStyle() + case InvisibleCharactersConfig.Symbols.lineFeed: + return lineFeedStyle() + case InvisibleCharactersConfig.Symbols.paragraphSeparator: + return paragraphSeparatorStyle() + case InvisibleCharactersConfig.Symbols.lineSeparator: + return lineSeparatorStyle() default: - guard config.warningCharacters.contains(character) else { - return nil - } - return .emphasize(color: .systemRed.withAlphaComponent(0.3)) + return warningCharacterStyle(for: character) } } + + private func spacesStyle(range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? { + guard config.showSpaces else { return nil } + let locationInLine = range.location - lineRange.location + let shouldBold = locationInLine % indentOption.charCount == indentOption.charCount - 1 + return .replace( + replacementCharacter: config.spaceReplacement, + color: invisibleColor, + font: shouldBold ? emphasizedFont : font + ) + } + + private func tabStyle() -> InvisibleCharacterStyle? { + guard config.showTabs else { return nil } + return .replace(replacementCharacter: config.tabReplacement, color: invisibleColor, font: font) + } + + private func carriageReturnStyle() -> InvisibleCharacterStyle? { + guard config.showLineEndings else { return nil } + return .replace(replacementCharacter: config.carriageReturnReplacement, color: invisibleColor, font: font) + } + + private func lineFeedStyle() -> InvisibleCharacterStyle? { + guard config.showLineEndings else { return nil } + return .replace(replacementCharacter: config.lineFeedReplacement, color: invisibleColor, font: font) + } + + private func paragraphSeparatorStyle() -> InvisibleCharacterStyle? { + guard config.showLineEndings else { return nil } + return .replace( + replacementCharacter: config.paragraphSeparatorReplacement, + color: invisibleColor, + font: font + ) + } + + private func lineSeparatorStyle() -> InvisibleCharacterStyle? { + guard config.showLineEndings else { return nil } + return .replace( + replacementCharacter: config.lineSeparatorReplacement, + color: invisibleColor, + font: font + ) + } + + private func warningCharacterStyle(for character: UInt16) -> InvisibleCharacterStyle? { + guard config.warningCharacters.contains(character) else { return nil } + return .emphasize(color: .systemRed.withAlphaComponent(0.3)) + } } From 81fe0d31d9f069f803269f47cd0f9f4fd6b7ad40 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:21:45 -0500 Subject: [PATCH 6/6] Split Warning Characters From Config, Add Tests --- .../CodeEditSourceEditor.swift | 34 ++++++--- .../Controller/TextViewController.swift | 37 +++++++--- .../InvisibleCharactersConfig.swift | 18 +---- .../InvisibleCharactersCoordinator.swift | 37 +++++++--- .../Controller/TextViewControllerTests.swift | 72 +++++++++++++++++++ 5 files changed, 155 insertions(+), 43 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 05b40591c..1a54bb826 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -50,10 +50,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. - /// - showMinimap: Whether to show the minimap - /// - reformatAtColumn: The column to reformat at - /// - showReformattingGuide: Whether to show the reformatting guide + /// - showMinimap: Whether to show the minimap. + /// - reformatAtColumn: The column to reformat at. + /// - showReformattingGuide: Whether to show the reformatting guide. /// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object. + /// See ``TextViewController/invisibleCharactersConfig`` and + /// ``InvisibleCharactersConfig`` for more information. + /// - warningCharacters: A set of characters the editor should draw with a small red border. See + /// ``TextViewController/warningCharacters`` for more information. public init( _ text: Binding, language: CodeLanguage, @@ -79,7 +83,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { showMinimap: Bool, reformatAtColumn: Int, showReformattingGuide: Bool, - invisibleCharactersConfig: InvisibleCharactersConfig = .empty + invisibleCharactersConfig: InvisibleCharactersConfig = .empty, + warningCharacters: Set = [] ) { self.text = .binding(text) self.language = language @@ -110,6 +115,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide self.invisibleCharactersConfig = invisibleCharactersConfig + self.warningCharacters = warningCharacters } /// Initializes a Text Editor @@ -139,10 +145,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// See `BracketPairEmphasis` for more information. Defaults to `nil` /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. - /// - showMinimap: Whether to show the minimap - /// - reformatAtColumn: The column to reformat at - /// - showReformattingGuide: Whether to show the reformatting guide + /// - showMinimap: Whether to show the minimap. + /// - reformatAtColumn: The column to reformat at. + /// - showReformattingGuide: Whether to show the reformatting guide. /// - invisibleCharactersConfig: Configuration for displaying invisible characters. Defaults to an empty object. + /// See ``TextViewController/invisibleCharactersConfig`` and + /// ``InvisibleCharactersConfig`` for more information. + /// - warningCharacters: A set of characters the editor should draw with a small red border. See + /// ``TextViewController/warningCharacters`` for more information. public init( _ text: NSTextStorage, language: CodeLanguage, @@ -168,7 +178,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { showMinimap: Bool, reformatAtColumn: Int, showReformattingGuide: Bool, - invisibleCharactersConfig: InvisibleCharactersConfig = .empty + invisibleCharactersConfig: InvisibleCharactersConfig = .empty, + warningCharacters: Set = [] ) { self.text = .storage(text) self.language = language @@ -199,6 +210,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide self.invisibleCharactersConfig = invisibleCharactersConfig + self.warningCharacters = warningCharacters } package var text: TextAPI @@ -226,6 +238,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var reformatAtColumn: Int private var showReformattingGuide: Bool private var invisibleCharactersConfig: InvisibleCharactersConfig + private var warningCharacters: Set public typealias NSViewControllerType = TextViewController @@ -364,6 +377,10 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if controller.invisibleCharactersConfig != invisibleCharactersConfig { controller.invisibleCharactersConfig = invisibleCharactersConfig } + + if controller.warningCharacters != warningCharacters { + controller.warningCharacters = warningCharacters + } } private func updateThemeAndLanguage(_ controller: TextViewController) { @@ -410,6 +427,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.reformatAtColumn == reformatAtColumn && controller.showReformattingGuide == showReformattingGuide && controller.invisibleCharactersConfig == invisibleCharactersConfig && + controller.warningCharacters == warningCharacters && areHighlightProvidersEqual(controller: controller, coordinator: coordinator) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 33e332419..f150dcce8 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -27,6 +27,15 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty var gutterView: GutterView! var minimapView: MinimapView! + /// The reformatting guide view + var guideView: ReformattingGuideView! { + didSet { + if let oldValue = oldValue { + oldValue.removeFromSuperview() + } + } + } + var minimapXConstraint: NSLayoutConstraint? var _undoManager: CEUndoManager! @@ -263,19 +272,10 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } - /// The reformatting guide view - var guideView: ReformattingGuideView! { - didSet { - if let oldValue = oldValue { - oldValue.removeFromSuperview() - } - } - } - /// Configuration for drawing invisible characters. /// /// See ``InvisibleCharactersConfig`` for more details. - var invisibleCharactersConfig: InvisibleCharactersConfig { + public var invisibleCharactersConfig: InvisibleCharactersConfig { get { invisibleCharactersCoordinator.config } @@ -284,6 +284,19 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } + /// A set of characters the editor should draw with a small red border. + /// + /// Indicates characters that the user may not have meant to insert, such as a zero-width space: `(0x200D)` or a + /// non-standard quote character: `“ (0x201C)`. + public var warningCharacters: Set { + get { + invisibleCharactersCoordinator.warningCharacters + } + set { + invisibleCharactersCoordinator.warningCharacters = newValue + } + } + // MARK: Init // Disabling function body length warning for now. There's an open issue for combining a lot of these parameters @@ -314,7 +327,8 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty showMinimap: Bool, reformatAtColumn: Int = 80, showReformattingGuide: Bool = false, - invisibleCharactersConfig: InvisibleCharactersConfig = .empty + invisibleCharactersConfig: InvisibleCharactersConfig = .empty, + warningCharacters: Set = [] ) { self.language = language self.font = font @@ -339,6 +353,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.showReformattingGuide = showReformattingGuide self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator( config: invisibleCharactersConfig, + warningCharacters: warningCharacters, indentOption: indentOption, theme: theme, font: font diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift index 74acf9bfb..5bb3f8100 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift @@ -12,7 +12,7 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable { /// An empty configuration. public static var empty: InvisibleCharactersConfig { - InvisibleCharactersConfig(showSpaces: false, showTabs: false, showLineEndings: false, warningCharacters: []) + InvisibleCharactersConfig(showSpaces: false, showTabs: false, showLineEndings: false) } /// Set to true to draw spaces with a dot. @@ -37,22 +37,10 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable /// Replacement when drawing the line separator character, enabled by ``showLineEndings``. public var lineSeparatorReplacement: String = "⏎" - /// A set of characters the editor should draw with a small red border. - /// - /// Indicates characters that the user may not have meant to insert, such as a zero-width space: `(0x200D)` or a - /// non-standard quote character: `“ (0x201C)`. - public var warningCharacters: Set - - public init( - showSpaces: Bool, - showTabs: Bool, - showLineEndings: Bool, - warningCharacters: Set - ) { + public init(showSpaces: Bool, showTabs: Bool, showLineEndings: Bool) { self.showSpaces = showSpaces self.showTabs = showTabs self.showLineEndings = showLineEndings - self.warningCharacters = warningCharacters } /// Determines what characters should trigger a custom drawing action. @@ -74,8 +62,6 @@ public struct InvisibleCharactersConfig: Equatable, Hashable, Sendable, Codable set.insert(Symbols.lineSeparator) } - set.formUnion(warningCharacters) - return set } diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift index da718278d..a2042d5cc 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -21,9 +21,19 @@ import CodeEditTextView final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { var config: InvisibleCharactersConfig { didSet { - triggerCharacters = config.triggerCharacters() + updateTriggerCharacters() } } + /// A set of characters the editor should draw with a small red border. + /// + /// Indicates characters that the user may not have meant to insert, such as a zero-width space: `(0x200D)` or a + /// non-standard quote character: `“ (0x201C)`. + public var warningCharacters: Set { + didSet { + updateTriggerCharacters() + } + } + var indentOption: IndentOption var theme: EditorTheme { didSet { @@ -43,19 +53,25 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { } } - private var needsCacheClear = false - private var invisibleColor: NSColor - private var emphasizedFont: NSFont + var needsCacheClear = false + var invisibleColor: NSColor + var emphasizedFont: NSFont /// The set of characters the text view should trigger a call to ``invisibleStyle`` for. - var triggerCharacters: Set + var triggerCharacters: Set = [] - init(config: InvisibleCharactersConfig, indentOption: IndentOption, theme: EditorTheme, font: NSFont) { + init( + config: InvisibleCharactersConfig, + warningCharacters: Set, + indentOption: IndentOption, + theme: EditorTheme, + font: NSFont + ) { self.config = config + self.warningCharacters = warningCharacters self.indentOption = indentOption self.theme = theme self.font = font - triggerCharacters = config.triggerCharacters() invisibleColor = theme.invisibles.color emphasizedFont = NSFontManager.shared.font( withFamily: font.familyName ?? "", @@ -63,6 +79,11 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { weight: 15, // Condensed size: font.pointSize ) ?? font + updateTriggerCharacters() + } + + private func updateTriggerCharacters() { + triggerCharacters = config.triggerCharacters().union(warningCharacters) } /// Determines if the textview should clear cached styles. @@ -144,7 +165,7 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { } private func warningCharacterStyle(for character: UInt16) -> InvisibleCharacterStyle? { - guard config.warningCharacters.contains(character) else { return nil } + guard warningCharacters.contains(character) else { return nil } return .emphasize(color: .systemRed.withAlphaComponent(0.3)) } } diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index abac077dc..0358b31b0 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -487,5 +487,77 @@ final class TextViewControllerTests: XCTestCase { lines = controller.getOverlappingLines(for: NSRange(location: 4, length: 1)) XCTAssertEqual(2...2, lines) } + + // MARK: - Invisible Characters + + func test_setInvisibleCharacterConfig() { + controller.setText(" Hello world") + controller.indentOption = .spaces(count: 4) + + XCTAssertEqual(controller.invisibleCharactersConfig, .empty) + + controller.invisibleCharactersConfig = .init(showSpaces: true, showTabs: true, showLineEndings: true) + XCTAssertEqual( + controller.invisibleCharactersConfig, + .init(showSpaces: true, showTabs: true, showLineEndings: true) + ) + XCTAssertEqual( + controller.invisibleCharactersCoordinator.config, + .init(showSpaces: true, showTabs: true, showLineEndings: true) + ) + + // Should emphasize the 4th space + XCTAssertEqual( + controller.invisibleCharactersCoordinator.invisibleStyle( + for: InvisibleCharactersConfig.Symbols.space, + at: NSRange(location: 3, length: 1), + lineRange: NSRange(location: 0, length: 15) + ), + .replace( + replacementCharacter: "·", + color: controller.theme.invisibles.color, + font: controller.invisibleCharactersCoordinator.emphasizedFont + ) + ) + XCTAssertEqual( + controller.invisibleCharactersCoordinator.invisibleStyle( + for: InvisibleCharactersConfig.Symbols.space, + at: NSRange(location: 4, length: 1), + lineRange: NSRange(location: 0, length: 15) + ), + .replace( + replacementCharacter: "·", + color: controller.theme.invisibles.color, + font: controller.font + ) + ) + + if case .emphasize = controller.invisibleCharactersCoordinator.invisibleStyle( + for: InvisibleCharactersConfig.Symbols.tab, + at: .zero, + lineRange: .zero + ) { + XCTFail("Incorrect character style for invisible character") + } + } + + // MARK: - Warning Characters + + func test_setWarningCharacterConfig() { + XCTAssertEqual(controller.warningCharacters, []) + + controller.warningCharacters = [0, 1] + + XCTAssertEqual(controller.warningCharacters, [0, 1]) + XCTAssertEqual(controller.invisibleCharactersCoordinator.warningCharacters, [0, 1]) + + if case .replace = controller.invisibleCharactersCoordinator.invisibleStyle( + for: 0, + at: .zero, + lineRange: .zero + ) { + XCTFail("Incorrect character style for warning character") + } + } } // swiftlint:enable all