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/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..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( diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 12a0f5a10..1a54bb826 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -50,9 +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, @@ -77,7 +82,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + invisibleCharactersConfig: InvisibleCharactersConfig = .empty, + warningCharacters: Set = [] ) { self.text = .binding(text) self.language = language @@ -107,6 +114,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.invisibleCharactersConfig = invisibleCharactersConfig + self.warningCharacters = warningCharacters } /// Initializes a Text Editor @@ -136,9 +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, @@ -163,7 +177,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + invisibleCharactersConfig: InvisibleCharactersConfig = .empty, + warningCharacters: Set = [] ) { self.text = .storage(text) self.language = language @@ -193,6 +209,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.invisibleCharactersConfig = invisibleCharactersConfig + self.warningCharacters = warningCharacters } package var text: TextAPI @@ -219,6 +237,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { package var showMinimap: Bool private var reformatAtColumn: Int private var showReformattingGuide: Bool + private var invisibleCharactersConfig: InvisibleCharactersConfig + private var warningCharacters: Set public typealias NSViewControllerType = TextViewController @@ -247,7 +267,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 +373,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if controller.useSystemCursor != useSystemCursor { controller.useSystemCursor = useSystemCursor } + + if controller.invisibleCharactersConfig != invisibleCharactersConfig { + controller.invisibleCharactersConfig = invisibleCharactersConfig + } + + if controller.warningCharacters != warningCharacters { + controller.warningCharacters = warningCharacters + } } private func updateThemeAndLanguage(_ controller: TextViewController) { @@ -397,6 +426,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.showMinimap == showMinimap && 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 8d3b8b69f..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! @@ -35,6 +44,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 +65,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty public var font: NSFont { didSet { textView.font = font + invisibleCharactersCoordinator.font = font highlighter?.invalidate() } } @@ -70,6 +84,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 +101,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty public var indentOption: IndentOption { didSet { setUpTextFormation() + invisibleCharactersCoordinator.indentOption = indentOption } } @@ -256,18 +272,37 @@ 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. + public var invisibleCharactersConfig: InvisibleCharactersConfig { + get { + invisibleCharactersCoordinator.config + } + set { + invisibleCharactersCoordinator.config = newValue + } + } + + /// 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 - 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, @@ -291,7 +326,9 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty coordinators: [TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int = 80, - showReformattingGuide: Bool = false + showReformattingGuide: Bool = false, + invisibleCharactersConfig: InvisibleCharactersConfig = .empty, + warningCharacters: Set = [] ) { self.language = language self.font = font @@ -314,14 +351,20 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator( + config: invisibleCharactersConfig, + warningCharacters: warningCharacters, + indentOption: indentOption, + theme: theme, + font: font + ) 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 }), @@ -342,6 +385,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) @@ -391,4 +436,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 new file mode 100644 index 000000000..5bb3f8100 --- /dev/null +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift @@ -0,0 +1,77 @@ +// +// InvisibleCharactersConfig.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/11/25. +// + +/// 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 { + InvisibleCharactersConfig(showSpaces: false, showTabs: false, showLineEndings: false) + } + + /// 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 = "⏎" + + public init(showSpaces: Bool, showTabs: Bool, showLineEndings: Bool) { + self.showSpaces = showSpaces + self.showTabs = showTabs + self.showLineEndings = showLineEndings + } + + /// 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.insert(Symbols.paragraphSeparator) + set.insert(Symbols.lineSeparator) + } + + 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 + 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 new file mode 100644 index 000000000..a2042d5cc --- /dev/null +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -0,0 +1,171 @@ +// +// 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 { + 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 { + 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 + } + } + + 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 = [] + + 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 + invisibleColor = theme.invisibles.color + emphasizedFont = NSFontManager.shared.font( + withFamily: font.familyName ?? "", + traits: .unboldFontMask, + weight: 15, // Condensed + size: font.pointSize + ) ?? font + updateTriggerCharacters() + } + + private func updateTriggerCharacters() { + triggerCharacters = config.triggerCharacters().union(warningCharacters) + } + + /// 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: + return spacesStyle(range: range, lineRange: lineRange) + case InvisibleCharactersConfig.Symbols.tab: + 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: + 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 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 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