From e56e677cfb3ec333537c654af9f990f9175c7b31 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 16 Jun 2025 16:05:43 -0500 Subject: [PATCH 01/23] Add Config Object - Still need to react to changes --- .../CodeEditSourceEditor.swift | 343 ++++----------- .../TextViewController+EmphasizeBracket.swift | 4 +- .../TextViewController+Highlighter.swift | 4 +- .../TextViewController+IndentLines.swift | 6 +- .../TextViewController+LoadView.swift | 12 +- .../TextViewController+ReloadUI.swift | 4 +- .../TextViewController+StyleViews.swift | 49 ++- .../TextViewController+TextFormation.swift | 17 +- .../Controller/TextViewController.swift | 402 ++++++++---------- .../EditorConfig+Appearance.swift | 67 +++ .../EditorConfig/EditorConfig+Behavior.swift | 35 ++ .../EditorConfig/EditorConfig+Layout.swift | 32 ++ .../EditorConfig+Peripherals.swift | 29 ++ .../EditorConfig/EditorConfig.swift | 27 ++ .../Gutter/GutterView.swift | 10 + .../ReformattingGuideView.swift | 8 + 16 files changed, 530 insertions(+), 519 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift create mode 100644 Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift create mode 100644 Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift create mode 100644 Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift create mode 100644 Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 12a0f5a10..e1a56762b 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -10,10 +10,6 @@ import SwiftUI import CodeEditTextView import CodeEditLanguages -// This type is messy, but it needs *so* many parameters that this is pretty much unavoidable. -// swiftlint:disable file_length -// swiftlint:disable type_body_length - /// A SwiftUI View that provides source editing functionality. public struct CodeEditSourceEditor: NSViewControllerRepresentable { package enum TextAPI { @@ -21,204 +17,69 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { case storage(NSTextStorage) } - /// Initializes a Text Editor + /// Initializes a new source editor /// - Parameters: /// - text: The text content /// - language: The language for syntax highlighting - /// - theme: The theme for syntax highlighting - /// - font: The default font - /// - tabWidth: The visual tab width in number of spaces - /// - indentOption: The behavior to use when the tab key is pressed. Defaults to 4 spaces. - /// - lineHeight: The line height multiplier (e.g. `1.2`) - /// - wrapLines: Whether lines wrap to the width of the editor - /// - editorOverscroll: The distance to overscroll the editor by. + /// - config: A configuration object, determining appearance, layout, behaviors and more. See ``EditorConfig``. /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` - /// - useThemeBackground: Determines whether the editor uses the theme's background color, or a transparent - /// background color /// - highlightProviders: A set of classes you provide to perform syntax highlighting. Leave this as `nil` to use /// the default `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. - /// - additionalTextInsets: An additional amount to inset the text of the editor by. - /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. - /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this - /// value is true, and `isEditable` is false, the editor is selectable but not editable. - /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a - /// character's width between characters, etc. Defaults to `1.0` - /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. - /// See `BracketPairHighlight` for more information. Defaults to `nil` - /// - 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 public init( _ text: Binding, language: CodeLanguage, - theme: EditorTheme, - font: NSFont, - tabWidth: Int, - indentOption: IndentOption = .spaces(count: 4), - lineHeight: Double, - wrapLines: Bool, - editorOverscroll: CGFloat = 0, + config: EditorConfig, cursorPositions: Binding<[CursorPosition]>, - useThemeBackground: Bool = true, highlightProviders: [any HighlightProviding]? = nil, - contentInsets: NSEdgeInsets? = nil, - additionalTextInsets: NSEdgeInsets? = nil, - isEditable: Bool = true, - isSelectable: Bool = true, - letterSpacing: Double = 1.0, - bracketPairEmphasis: BracketPairEmphasis? = .flash, - useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, - coordinators: [any TextViewCoordinator] = [], - showMinimap: Bool, - reformatAtColumn: Int, - showReformattingGuide: Bool + coordinators: [any TextViewCoordinator] = [] ) { self.text = .binding(text) self.language = language - self.theme = theme - self.useThemeBackground = useThemeBackground - self.font = font - self.tabWidth = tabWidth - self.indentOption = indentOption - self.lineHeight = lineHeight - self.wrapLines = wrapLines - self.editorOverscroll = editorOverscroll + self.config = config self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders - self.contentInsets = contentInsets - self.additionalTextInsets = additionalTextInsets - self.isEditable = isEditable - self.isSelectable = isSelectable - self.letterSpacing = letterSpacing - self.bracketPairEmphasis = bracketPairEmphasis - if #available(macOS 14, *) { - self.useSystemCursor = useSystemCursor - } else { - self.useSystemCursor = false - } self.undoManager = undoManager self.coordinators = coordinators - self.showMinimap = showMinimap - self.reformatAtColumn = reformatAtColumn - self.showReformattingGuide = showReformattingGuide } - /// Initializes a Text Editor + /// Initializes a new source editor /// - Parameters: /// - text: The text content /// - language: The language for syntax highlighting - /// - theme: The theme for syntax highlighting - /// - font: The default font - /// - tabWidth: The visual tab width in number of spaces - /// - indentOption: The behavior to use when the tab key is pressed. Defaults to 4 spaces. - /// - lineHeight: The line height multiplier (e.g. `1.2`) - /// - wrapLines: Whether lines wrap to the width of the editor - /// - editorOverscroll: The distance to overscroll the editor by. + /// - config: A configuration object, determining appearance, layout, behaviors and more. See ``EditorConfig``. /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` - /// - useThemeBackground: Determines whether the editor uses the theme's background color, or a transparent - /// background color /// - highlightProviders: A set of classes you provide to perform syntax highlighting. Leave this as `nil` to use /// the default `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. - /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this - /// value is true, and `isEditable` is false, the editor is selectable but not editable. - /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a - /// character's width between characters, etc. Defaults to `1.0` - /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. - /// 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 public init( _ text: NSTextStorage, language: CodeLanguage, - theme: EditorTheme, - font: NSFont, - tabWidth: Int, - indentOption: IndentOption = .spaces(count: 4), - lineHeight: Double, - wrapLines: Bool, - editorOverscroll: CGFloat = 0, + config: EditorConfig, cursorPositions: Binding<[CursorPosition]>, - useThemeBackground: Bool = true, highlightProviders: [any HighlightProviding]? = nil, - contentInsets: NSEdgeInsets? = nil, - additionalTextInsets: NSEdgeInsets? = nil, - isEditable: Bool = true, - isSelectable: Bool = true, - letterSpacing: Double = 1.0, - bracketPairEmphasis: BracketPairEmphasis? = .flash, - useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, - coordinators: [any TextViewCoordinator] = [], - showMinimap: Bool, - reformatAtColumn: Int, - showReformattingGuide: Bool + coordinators: [any TextViewCoordinator] = [] ) { self.text = .storage(text) self.language = language - self.theme = theme - self.useThemeBackground = useThemeBackground - self.font = font - self.tabWidth = tabWidth - self.indentOption = indentOption - self.lineHeight = lineHeight - self.wrapLines = wrapLines - self.editorOverscroll = editorOverscroll + self.config = config self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders - self.contentInsets = contentInsets - self.additionalTextInsets = additionalTextInsets - self.isEditable = isEditable - self.isSelectable = isSelectable - self.letterSpacing = letterSpacing - self.bracketPairEmphasis = bracketPairEmphasis - if #available(macOS 14, *) { - self.useSystemCursor = useSystemCursor - } else { - self.useSystemCursor = false - } self.undoManager = undoManager self.coordinators = coordinators - self.showMinimap = showMinimap - self.reformatAtColumn = reformatAtColumn - self.showReformattingGuide = showReformattingGuide } package var text: TextAPI private var language: CodeLanguage - private var theme: EditorTheme - private var font: NSFont - private var tabWidth: Int - private var indentOption: IndentOption - private var lineHeight: Double - private var wrapLines: Bool - private var editorOverscroll: CGFloat + private var config: EditorConfig package var cursorPositions: Binding<[CursorPosition]> - private var useThemeBackground: Bool private var highlightProviders: [any HighlightProviding]? - private var contentInsets: NSEdgeInsets? - private var additionalTextInsets: NSEdgeInsets? - private var isEditable: Bool - private var isSelectable: Bool - private var letterSpacing: Double - private var bracketPairEmphasis: BracketPairEmphasis? - private var useSystemCursor: Bool private var undoManager: CEUndoManager? package var coordinators: [any TextViewCoordinator] - package var showMinimap: Bool - private var reformatAtColumn: Int - private var showReformattingGuide: Bool public typealias NSViewControllerType = TextViewController @@ -226,28 +87,11 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { let controller = TextViewController( string: "", language: language, - font: font, - theme: theme, - tabWidth: tabWidth, - indentOption: indentOption, - lineHeight: lineHeight, - wrapLines: wrapLines, + config: config, cursorPositions: cursorPositions.wrappedValue, - editorOverscroll: editorOverscroll, - useThemeBackground: useThemeBackground, highlightProviders: context.coordinator.highlightProviders, - contentInsets: contentInsets, - additionalTextInsets: additionalTextInsets, - isEditable: isEditable, - isSelectable: isSelectable, - letterSpacing: letterSpacing, - useSystemCursor: useSystemCursor, - bracketPairEmphasis: bracketPairEmphasis, undoManager: undoManager, coordinators: coordinators, - showMinimap: showMinimap, - reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide ) switch text { case .binding(let binding): @@ -300,103 +144,86 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// Update the parameters of the controller. /// - Parameter controller: The controller to update. func updateControllerParams(controller: TextViewController, coordinator: Coordinator) { - updateTextProperties(controller) - updateEditorProperties(controller) - updateThemeAndLanguage(controller) - updateHighlighting(controller, coordinator: coordinator) - - if controller.reformatAtColumn != reformatAtColumn { - controller.reformatAtColumn = reformatAtColumn - } - - if controller.showReformattingGuide != showReformattingGuide { - controller.showReformattingGuide = showReformattingGuide - } - } - - private func updateTextProperties(_ controller: TextViewController) { - if controller.font != font { - controller.font = font - } - - if controller.isEditable != isEditable { - controller.isEditable = isEditable - } - - if controller.isSelectable != isSelectable { - controller.isSelectable = isSelectable - } - } - - private func updateEditorProperties(_ controller: TextViewController) { - controller.wrapLines = wrapLines - controller.useThemeBackground = useThemeBackground - controller.lineHeightMultiple = lineHeight - controller.editorOverscroll = editorOverscroll - controller.contentInsets = contentInsets - controller.additionalTextInsets = additionalTextInsets - controller.showMinimap = showMinimap - - if controller.indentOption != indentOption { - controller.indentOption = indentOption - } - - if controller.tabWidth != tabWidth { - controller.tabWidth = tabWidth - } - - if controller.letterSpacing != letterSpacing { - controller.letterSpacing = letterSpacing - } - - if controller.useSystemCursor != useSystemCursor { - controller.useSystemCursor = useSystemCursor - } - } - - private func updateThemeAndLanguage(_ controller: TextViewController) { - if controller.language.id != language.id { - controller.language = language - } - - if controller.theme != theme { - controller.theme = theme - } - } - - private func updateHighlighting(_ controller: TextViewController, coordinator: Coordinator) { - if !areHighlightProvidersEqual(controller: controller, coordinator: coordinator) { - controller.setHighlightProviders(coordinator.highlightProviders) - } - - if controller.bracketPairEmphasis != bracketPairEmphasis { - controller.bracketPairEmphasis = bracketPairEmphasis - } +// updateTextProperties(controller) +// updateEditorProperties(controller) +// updateThemeAndLanguage(controller) +// updateHighlighting(controller, coordinator: coordinator) +// +// if controller.reformatAtColumn != reformatAtColumn { +// controller.reformatAtColumn = reformatAtColumn +// } +// +// if controller.showReformattingGuide != showReformattingGuide { +// controller.showReformattingGuide = showReformattingGuide +// } } +// +// private func updateTextProperties(_ controller: TextViewController) { +// if controller.font != font { +// controller.font = font +// } +// +// if controller.isEditable != isEditable { +// controller.isEditable = isEditable +// } +// +// if controller.isSelectable != isSelectable { +// controller.isSelectable = isSelectable +// } +// } +// +// private func updateEditorProperties(_ controller: TextViewController) { +// controller.wrapLines = wrapLines +// controller.useThemeBackground = useThemeBackground +// controller.lineHeightMultiple = lineHeight +// controller.editorOverscroll = editorOverscroll +// controller.contentInsets = contentInsets +// controller.additionalTextInsets = additionalTextInsets +// controller.showMinimap = showMinimap +// +// if controller.indentOption != indentOption { +// controller.indentOption = indentOption +// } +// +// if controller.tabWidth != tabWidth { +// controller.tabWidth = tabWidth +// } +// +// if controller.letterSpacing != letterSpacing { +// controller.letterSpacing = letterSpacing +// } +// +// if controller.useSystemCursor != useSystemCursor { +// controller.useSystemCursor = useSystemCursor +// } +// } +// +// private func updateThemeAndLanguage(_ controller: TextViewController) { +// if controller.language.id != language.id { +// controller.language = language +// } +// +// if controller.theme != theme { +// controller.theme = theme +// } +// } +// +// private func updateHighlighting(_ controller: TextViewController, coordinator: Coordinator) { +// if !areHighlightProvidersEqual(controller: controller, coordinator: coordinator) { +// controller.setHighlightProviders(coordinator.highlightProviders) +// } +// +// if controller.bracketPairEmphasis != bracketPairEmphasis { +// controller.bracketPairEmphasis = bracketPairEmphasis +// } +// } /// Checks if the controller needs updating. /// - Parameter controller: The controller to check. /// - Returns: True, if the controller's parameters should be updated. func paramsAreEqual(controller: NSViewControllerType, coordinator: Coordinator) -> Bool { - controller.font == font && - controller.isEditable == isEditable && - controller.isSelectable == isSelectable && - controller.wrapLines == wrapLines && - controller.useThemeBackground == useThemeBackground && - controller.lineHeightMultiple == lineHeight && - controller.editorOverscroll == editorOverscroll && - controller.contentInsets == contentInsets && - controller.additionalTextInsets == additionalTextInsets && controller.language.id == language.id && - controller.theme == theme && - controller.indentOption == indentOption && - controller.tabWidth == tabWidth && - controller.letterSpacing == letterSpacing && - controller.bracketPairEmphasis == bracketPairEmphasis && - controller.useSystemCursor == useSystemCursor && - controller.showMinimap == showMinimap && - controller.reformatAtColumn == reformatAtColumn && - controller.showReformattingGuide == showReformattingGuide && + controller.config == config && areHighlightProvidersEqual(controller: controller, coordinator: coordinator) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift index 87a3c49cb..3a2970382 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift @@ -11,7 +11,7 @@ import CodeEditTextView extension TextViewController { /// Emphasizes bracket pairs using the current selection. internal func emphasizeSelectionPairs() { - guard let bracketPairEmphasis else { return } + guard let bracketPairEmphasis = config.appearance.bracketPairEmphasis else { return } textView.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) for range in textView.selectionManager.textSelections.map({ $0.range }) { if range.isEmpty, @@ -119,7 +119,7 @@ extension TextViewController { /// - location: The location of the character to emphasize /// - scrollToRange: Set to true to scroll to the given range when emphasizing. Defaults to `false`. private func emphasizeCharacter(_ location: Int, scrollToRange: Bool = false) { - guard let bracketPairEmphasis = bracketPairEmphasis else { + guard let bracketPairEmphasis = config.appearance.bracketPairEmphasis else { return } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift index db9beaec9..d96ca1bc0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -41,8 +41,8 @@ extension TextViewController { extension TextViewController: ThemeAttributesProviding { public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { [ - .font: theme.fontFor(for: capture, from: font), - .foregroundColor: theme.colorFor(capture), + .font: config.appearance.theme.fontFor(for: capture, from: config.appearance.font), + .foregroundColor: config.appearance.theme.colorFor(capture), .kern: textView.kern ] } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 2a4ae1254..019171f0c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -97,7 +97,7 @@ extension TextViewController { lineCount: lineCount ) - let charCount = indentOption.charCount + let charCount = config.behavior.indentOption.charCount selection.range.location += inwards ? -charCount * sectionModifier : charCount * sectionModifier if lineCount > 1 { @@ -169,7 +169,7 @@ extension TextViewController { } private func adjustIndentation(lineIndexes: ClosedRange, inwards: Bool) { - let indentationChars: String = indentOption.stringValue + let indentationChars: String = config.behavior.indentOption.stringValue for lineIndex in lineIndexes { adjustIndentation( lineIndex: lineIndex, @@ -183,7 +183,7 @@ extension TextViewController { guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } if inwards { - if indentOption != .tab { + if config.behavior.indentOption != .tab { removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count) } else { removeLeadingTab(lineInfo: lineInfo) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 9b7028a9e..f3f29ab56 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -16,25 +16,19 @@ extension TextViewController { scrollView.documentView = textView gutterView = GutterView( - font: font.rulerFont, - textColor: theme.text.color.withAlphaComponent(0.35), - selectedTextColor: theme.text.color, + config: config, textView: textView, delegate: self ) gutterView.updateWidthIfNeeded() scrollView.addFloatingSubview(gutterView, for: .horizontal) - guideView = ReformattingGuideView( - column: self.reformatAtColumn, - isVisible: self.showReformattingGuide, - theme: theme - ) + guideView = ReformattingGuideView(config: config) guideView.wantsLayer = true scrollView.addFloatingSubview(guideView, for: .vertical) guideView.updatePosition(in: textView) - minimapView = MinimapView(textView: textView, theme: theme) + minimapView = MinimapView(textView: textView, theme: config.appearance.theme) scrollView.addFloatingSubview(minimapView, for: .vertical) let findViewController = FindViewController(target: self, childView: scrollView) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift index d5ec302b8..aa7b4d5fd 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift @@ -9,8 +9,8 @@ import AppKit extension TextViewController { func reloadUI() { - textView.isEditable = isEditable - textView.isSelectable = isSelectable + textView.isEditable = config.behavior.isEditable + textView.isSelectable = config.behavior.isSelectable styleScrollView() styleTextView() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 8fdc5f478..e42be22cc 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -13,7 +13,7 @@ extension TextViewController { // swiftlint:disable:next force_cast let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraph.tabStops.removeAll() - paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth + paragraph.defaultTabInterval = CGFloat(config.appearance.tabWidth) * fontCharWidth return paragraph } @@ -21,11 +21,15 @@ extension TextViewController { package func styleTextView() { textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false - textView.selectionManager.selectionBackgroundColor = theme.selection + textView.selectionManager.selectionBackgroundColor = config.appearance.theme.selection textView.selectionManager.selectedLineBackgroundColor = getThemeBackground() - textView.selectionManager.highlightSelectedLine = isEditable - textView.selectionManager.insertionPointColor = theme.insertionPoint - textView.enclosingScrollView?.backgroundColor = useThemeBackground ? theme.background : .clear + textView.selectionManager.highlightSelectedLine = config.behavior.isEditable + textView.selectionManager.insertionPointColor = config.appearance.theme.insertionPoint + textView.enclosingScrollView?.backgroundColor = if config.appearance.useThemeBackground { + config.appearance.theme.background + } else { + .clear + } paragraphStyle = generateParagraphStyle() textView.typingAttributes = attributesFor(nil) } @@ -33,8 +37,8 @@ extension TextViewController { /// Finds the preferred use theme background. /// - Returns: The background color to use. private func getThemeBackground() -> NSColor { - if useThemeBackground { - return theme.lineHighlight + if config.appearance.useThemeBackground { + return config.appearance.theme.lineHighlight } if systemAppearance == .darkAqua { @@ -46,13 +50,21 @@ extension TextViewController { /// Style the gutter view. package func styleGutterView() { - gutterView.selectedLineColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua - ? NSColor.quaternaryLabelColor - : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - gutterView.highlightSelectedLines = isEditable - gutterView.font = font.rulerFont - gutterView.backgroundColor = useThemeBackground ? theme.background : .windowBackgroundColor - if self.isEditable == false { + gutterView.selectedLineColor = if config.appearance.useThemeBackground { + config.appearance.theme.lineHighlight + } else if systemAppearance == .darkAqua { + NSColor.quaternaryLabelColor + } else { + NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + } + gutterView.highlightSelectedLines = config.behavior.isEditable + gutterView.font = config.appearance.font.rulerFont + gutterView.backgroundColor = if config.appearance.useThemeBackground { + config.appearance.theme.background + } else { + .windowBackgroundColor + } + if config.behavior.isEditable == false { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear } @@ -63,13 +75,13 @@ extension TextViewController { scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.contentView.postsFrameChangedNotifications = true scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = !wrapLines + scrollView.hasHorizontalScroller = !config.appearance.wrapLines scrollView.scrollerStyle = .overlay } package func styleMinimapView() { minimapView.postsFrameChangedNotifications = true - minimapView.isHidden = !showMinimap + minimapView.isHidden = !config.peripherals.showMinimap } /// Updates all relevant content insets including the find panel, scroll view, minimap and gutter position. @@ -77,7 +89,7 @@ extension TextViewController { updateTextInsets() scrollView.contentView.postsBoundsChangedNotifications = true - if let contentInsets { + if let contentInsets = config.layout.contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets @@ -90,6 +102,7 @@ extension TextViewController { } // `additionalTextInsets` only effects text content. + let additionalTextInsets = config.layout.additionalTextInsets scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 minimapView.scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 @@ -104,7 +117,7 @@ extension TextViewController { scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset - findViewController?.topPadding = contentInsets?.top + findViewController?.topPadding = config.layout.contentInsets?.top gutterView.frame.origin.y = textView.frame.origin.y - scrollView.contentInsets.top diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 002e3807b..24b3296c3 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -38,9 +38,9 @@ extension TextViewController { setUpOpenPairFilters(pairs: BracketPairs.allValues) setUpTagFilter() - setUpNewlineTabFilters(indentOption: indentOption) + setUpNewlineTabFilters(indentOption: config.behavior.indentOption) setUpDeletePairFilters(pairs: BracketPairs.allValues) - setUpDeleteWhitespaceFilter(indentOption: indentOption) + setUpDeleteWhitespaceFilter(indentOption: config.behavior.indentOption) } /// Returns a `TextualIndenter` based on available language configuration. @@ -95,7 +95,7 @@ extension TextViewController { guard let treeSitterClient, language.id.shouldProcessTags() else { return } textFilters.append(TagFilter( language: self.language, - indentOption: indentOption, + indentOption: config.behavior.indentOption, lineEnding: textView.layoutManager.detectedLineEnding, treeSitterClient: treeSitterClient )) @@ -112,12 +112,15 @@ extension TextViewController { return true } - let indentationUnit = indentOption.stringValue + let indentationUnit = config.behavior.indentOption.stringValue let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( - leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, - width: tabWidth), - trailingWhitespace: { _, _ in "" } + leadingWhitespace: indenter.substitionProvider( + indentationUnit: indentationUnit, + width: config.appearance.tabWidth + ), + trailingWhitespace: { _, _ in "" + } ) for filter in textFilters { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 8d3b8b69f..a8f16ca79 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -16,7 +16,7 @@ import TextFormation /// /// A view controller class for managing a source editor. Uses ``CodeEditTextView/TextView`` for input and rendering, /// tree-sitter for syntax highlighting, and TextFormation for live editing completions. -public class TextViewController: NSViewController { // swiftlint:disable:this type_body_length +public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") @@ -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! @@ -48,129 +57,131 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } - /// The font to use in the `textView` - public var font: NSFont { - didSet { - textView.font = font - highlighter?.invalidate() - } - } + public var config: EditorConfig - /// The associated `Theme` used for highlighting. - public var theme: EditorTheme { - didSet { - textView.layoutManager.setNeedsLayout() - textView.textStorage.setAttributes( - attributesFor(nil), - range: NSRange(location: 0, length: textView.textStorage.length) - ) - textView.selectionManager.selectedLineBackgroundColor = theme.selection - highlighter?.invalidate() - gutterView.textColor = theme.text.color.withAlphaComponent(0.35) - gutterView.selectedLineTextColor = theme.text.color - minimapView.setTheme(theme) - guideView?.setTheme(theme) - } - } - - /// The visual width of tab characters in the text view measured in number of spaces. - public var tabWidth: Int { - didSet { - paragraphStyle = generateParagraphStyle() - textView.layoutManager.setNeedsLayout() - highlighter?.invalidate() - } - } - - /// The behavior to use when the tab key is pressed. - public var indentOption: IndentOption { - didSet { - setUpTextFormation() - } - } - - /// A multiplier for setting the line height. Defaults to `1.0` - public var lineHeightMultiple: CGFloat { - didSet { - textView.layoutManager.lineHeightMultiplier = lineHeightMultiple - } - } - - /// Whether lines wrap to the width of the editor - public var wrapLines: Bool { - didSet { - textView.layoutManager.wrapLines = wrapLines - minimapView.layoutManager?.wrapLines = wrapLines - scrollView.hasHorizontalScroller = !wrapLines - updateTextInsets() - } - } +// /// The font to use in the `textView` +// public var font: NSFont { +// didSet { +// textView.font = font +// highlighter?.invalidate() +// } +// } +// +// /// The associated `Theme` used for highlighting. +// public var theme: EditorTheme { +// didSet { +// textView.layoutManager.setNeedsLayout() +// textView.textStorage.setAttributes( +// attributesFor(nil), +// range: NSRange(location: 0, length: textView.textStorage.length) +// ) +// textView.selectionManager.selectedLineBackgroundColor = theme.selection +// highlighter?.invalidate() +// gutterView.textColor = theme.text.color.withAlphaComponent(0.35) +// gutterView.selectedLineTextColor = theme.text.color +// minimapView.setTheme(theme) +// guideView?.setTheme(theme) +// } +// } +// +// /// The visual width of tab characters in the text view measured in number of spaces. +// public var tabWidth: Int { +// didSet { +// paragraphStyle = generateParagraphStyle() +// textView.layoutManager.setNeedsLayout() +// highlighter?.invalidate() +// } +// } +// +// /// The behavior to use when the tab key is pressed. +// public var indentOption: IndentOption { +// didSet { +// setUpTextFormation() +// } +// } +// +// /// A multiplier for setting the line height. Defaults to `1.0` +// public var lineHeightMultiple: CGFloat { +// didSet { +// textView.layoutManager.lineHeightMultiplier = lineHeightMultiple +// } +// } +// +// /// Whether lines wrap to the width of the editor +// public var wrapLines: Bool { +// didSet { +// textView.layoutManager.wrapLines = wrapLines +// minimapView.layoutManager?.wrapLines = wrapLines +// scrollView.hasHorizontalScroller = !wrapLines +// updateTextInsets() +// } +// } /// The current cursors' positions ordered by the location of the cursor. internal(set) public var cursorPositions: [CursorPosition] = [] - /// The editorOverscroll to use for the textView over scroll - /// - /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll - /// of 1/3 of the view. - public var editorOverscroll: CGFloat { - didSet { - textView.overscrollAmount = editorOverscroll - } - } - - /// Whether the code editor should use the theme background color or be transparent - public var useThemeBackground: Bool +// /// The editorOverscroll to use for the textView over scroll +// /// +// /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll +// /// of 1/3 of the view. +// public var editorOverscroll: CGFloat { +// didSet { +// textView.overscrollAmount = editorOverscroll +// } +// } +// +// /// Whether the code editor should use the theme background color or be transparent +// public var useThemeBackground: Bool /// The provided highlight provider. public var highlightProviders: [HighlightProviding] - /// Optional insets to offset the text view and find panel in the scroll view by. - public var contentInsets: NSEdgeInsets? { - didSet { - updateContentInsets() - } - } - - /// An additional amount to inset text by. Horizontal values are ignored. - /// - /// This value does not affect decorations like the find panel, but affects things that are relative to text, such - /// as line numbers and of course the text itself. - public var additionalTextInsets: NSEdgeInsets? { - didSet { - styleScrollView() - } - } - - /// Whether or not text view is editable by user - public var isEditable: Bool { - didSet { - textView.isEditable = isEditable - } - } - - /// Whether or not text view is selectable by user - public var isSelectable: Bool { - didSet { - textView.isSelectable = isSelectable - } - } - - /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, - /// `2.0` indicates one character of space between other characters. - public var letterSpacing: Double = 1.0 { - didSet { - textView.letterSpacing = letterSpacing - highlighter?.invalidate() - } - } - - /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. - public var bracketPairEmphasis: BracketPairEmphasis? { - didSet { - emphasizeSelectionPairs() - } - } +// /// Optional insets to offset the text view and find panel in the scroll view by. +// public var contentInsets: NSEdgeInsets? { +// didSet { +// updateContentInsets() +// } +// } +// +// /// An additional amount to inset text by. Horizontal values are ignored. +// /// +// /// This value does not affect decorations like the find panel, but affects things that are relative to text, such +// /// as line numbers and of course the text itself. +// public var additionalTextInsets: NSEdgeInsets? { +// didSet { +// styleScrollView() +// } +// } +// +// /// Whether or not text view is editable by user +// public var isEditable: Bool { +// didSet { +// textView.isEditable = isEditable +// } +// } +// +// /// Whether or not text view is selectable by user +// public var isSelectable: Bool { +// didSet { +// textView.isSelectable = isSelectable +// } +// } +// +// /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, +// /// `2.0` indicates one character of space between other characters. +// public var letterSpacing: Double = 1.0 { +// didSet { +// textView.letterSpacing = letterSpacing +// highlighter?.invalidate() +// } +// } +// +// /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. +// public var bracketPairEmphasis: BracketPairEmphasis? { +// didSet { +// emphasizeSelectionPairs() +// } +// } /// Passthrough value for the `textView`s string public var text: String { @@ -182,27 +193,27 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } - /// If true, uses the system cursor on macOS 14 or greater. - public var useSystemCursor: Bool { - get { - textView.useSystemCursor - } - set { - if #available(macOS 14, *) { - textView.useSystemCursor = newValue - } - } - } - - public var showMinimap: Bool { - didSet { - minimapView?.isHidden = !showMinimap - if scrollView != nil { // Check for view existence - updateContentInsets() - updateTextInsets() - } - } - } +// /// If true, uses the system cursor on macOS 14 or greater. +// public var useSystemCursor: Bool { +// get { +// textView.useSystemCursor +// } +// set { +// if #available(macOS 14, *) { +// textView.useSystemCursor = newValue +// } +// } +// } +// +// public var showMinimap: Bool { +// didSet { +// minimapView?.isHidden = !showMinimap +// if scrollView != nil { // Check for view existence +// updateContentInsets() +// updateTextInsets() +// } +// } +// } var textCoordinators: [WeakCoordinator] = [] @@ -213,7 +224,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty /// This will be `nil` if another highlighter provider is passed to the source editor. internal(set) public var treeSitterClient: TreeSitterClient? - package var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } + package var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: config.appearance.font]).width } /// Filters used when applying edits.. internal var textFilters: [TextFormation.Filter] = [] @@ -234,96 +245,47 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty ) } - /// The column at which to show the reformatting guide - public var reformatAtColumn: Int = 80 { - didSet { - if let guideView = self.guideView { - guideView.setColumn(reformatAtColumn) - guideView.updatePosition(in: textView) - guideView.needsDisplay = true - } - } - } - - /// Whether to show the reformatting guide - public var showReformattingGuide: Bool = false { - didSet { - if let guideView = self.guideView { - guideView.setVisible(showReformattingGuide) - guideView.updatePosition(in: textView) - guideView.needsDisplay = true - } - } - } - - /// The reformatting guide view - var guideView: ReformattingGuideView! { - didSet { - if let oldValue = oldValue { - oldValue.removeFromSuperview() - } - } - } +// /// The column at which to show the reformatting guide +// public var reformatAtColumn: Int = 80 { +// didSet { +// if let guideView = self.guideView { +// guideView.setColumn(reformatAtColumn) +// guideView.updatePosition(in: textView) +// guideView.needsDisplay = true +// } +// } +// } +// +// /// Whether to show the reformatting guide +// public var showReformattingGuide: Bool = false { +// didSet { +// if let guideView = self.guideView { +// guideView.setVisible(showReformattingGuide) +// guideView.updatePosition(in: textView) +// guideView.needsDisplay = true +// } +// } +// } // MARK: Init init( string: String, language: CodeLanguage, - font: NSFont, - theme: EditorTheme, - tabWidth: Int, - indentOption: IndentOption, - lineHeight: CGFloat, - wrapLines: Bool, + config: EditorConfig, cursorPositions: [CursorPosition], - editorOverscroll: CGFloat, - useThemeBackground: Bool, highlightProviders: [HighlightProviding] = [TreeSitterClient()], - contentInsets: NSEdgeInsets?, - additionalTextInsets: NSEdgeInsets? = nil, - isEditable: Bool, - isSelectable: Bool, - letterSpacing: Double, - useSystemCursor: Bool, - bracketPairEmphasis: BracketPairEmphasis?, undoManager: CEUndoManager? = nil, coordinators: [TextViewCoordinator] = [], - showMinimap: Bool, - reformatAtColumn: Int = 80, - showReformattingGuide: Bool = false ) { self.language = language - self.font = font - self.theme = theme - self.tabWidth = tabWidth - self.indentOption = indentOption - self.lineHeightMultiple = lineHeight - self.wrapLines = wrapLines + self.config = config self.cursorPositions = cursorPositions - self.editorOverscroll = editorOverscroll - self.useThemeBackground = useThemeBackground self.highlightProviders = highlightProviders - self.contentInsets = contentInsets - self.additionalTextInsets = additionalTextInsets - self.isEditable = isEditable - self.isSelectable = isSelectable - self.letterSpacing = letterSpacing - self.bracketPairEmphasis = bracketPairEmphasis self._undoManager = undoManager - self.showMinimap = showMinimap - self.reformatAtColumn = reformatAtColumn - self.showReformattingGuide = showReformattingGuide super.init(nibName: nil, bundle: nil) - let platformGuardedSystemCursor: Bool - if #available(macOS 14, *) { - platformGuardedSystemCursor = useSystemCursor - } else { - platformGuardedSystemCursor = false - } - if let idx = highlightProviders.firstIndex(where: { $0 is TreeSitterClient }), let client = highlightProviders[idx] as? TreeSitterClient { self.treeSitterClient = client @@ -331,19 +293,23 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.textView = TextView( string: string, - font: font, - textColor: theme.text.color, - lineHeightMultiplier: lineHeightMultiple, - wrapLines: wrapLines, - isEditable: isEditable, - isSelectable: isSelectable, - letterSpacing: letterSpacing, - useSystemCursor: platformGuardedSystemCursor, + font: config.appearance.font, + textColor: config.appearance.theme.text.color, + lineHeightMultiplier: config.appearance.lineHeight, + wrapLines: config.appearance.wrapLines, + isEditable: config.behavior.isEditable, + isSelectable: config.behavior.isSelectable, + letterSpacing: config.appearance.letterSpacing, + useSystemCursor: config.appearance.useSystemCursor, delegate: self ) // Initialize guide view - self.guideView = ReformattingGuideView(column: reformatAtColumn, isVisible: showReformattingGuide, theme: theme) + self.guideView = ReformattingGuideView( + column: config.behavior.reformatAtColumn, + isVisible: config.peripherals.showReformattingGuide, + theme: config.appearance.theme + ) coordinators.forEach { $0.prepareCoordinator(controller: self) diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift new file mode 100644 index 000000000..7b6f3ec48 --- /dev/null +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift @@ -0,0 +1,67 @@ +// +// EditorConfig+Appearance.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/16/25. +// + +import AppKit + +extension EditorConfig { + public struct Appearance: Equatable { + /// The theme for syntax highlighting. + public var theme: EditorTheme + + /// Determines whether the editor uses the theme's background color, or a transparent background color. + public var useThemeBackground: Bool = true + + /// The default font. + public var font: NSFont + + /// The line height multiplier (e.g. `1.2`). + public var lineHeight: Double + + /// The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a + /// character's width between characters, etc. Defaults to `1.0`. + public var letterSpacing: Double = 1.0 + + /// Whether lines wrap to the width of the editor. + public var wrapLines: Bool + + /// If true, uses the system cursor on `>=macOS 14`. + public var useSystemCursor: Bool = true + + /// The visual tab width in number of spaces. + public var tabWidth: Int + + /// The type of highlight to use to highlight bracket pairs. + /// See ``BracketPairEmphasis`` for more information. Defaults to `.flash`. + public var bracketPairEmphasis: BracketPairEmphasis? = .flash + + public init( + theme: EditorTheme, + useThemeBackground: Bool = true, + font: NSFont, + lineHeight: Double, + letterSpacing: Double = 1.0, + wrapLines: Bool, + useSystemCursor: Bool = true, + tabWidth: Int, + bracketPairEmphasis: BracketPairEmphasis? = .flash + ) { + self.theme = theme + self.useThemeBackground = useThemeBackground + self.font = font + self.lineHeight = lineHeight + self.letterSpacing = letterSpacing + self.wrapLines = wrapLines + if #available(macOS 14, *) { + self.useSystemCursor = useSystemCursor + } else { + self.useSystemCursor = false + } + self.tabWidth = tabWidth + self.bracketPairEmphasis = bracketPairEmphasis + } + } +} diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift new file mode 100644 index 000000000..6b6b5e854 --- /dev/null +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift @@ -0,0 +1,35 @@ +// +// EditorConfig+Behavior.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/16/25. +// + +extension EditorConfig { + public struct Behavior: Equatable { + /// Controls whether the text view allows the user to edit text. + public var isEditable: Bool = true + + /// Controls whether the text view allows the user to select text. If this value is true, and `isEditable` is + /// false, the editor is selectable but not editable. + public var isSelectable: Bool = true + + /// Determines what character(s) to insert when the tab key is pressed. Defaults to 4 spaces. + public var indentOption: IndentOption = .spaces(count: 4) + + /// The column to reformat at. + public var reformatAtColumn: Int + + public init( + isEditable: Bool = true, + isSelectable: Bool = true, + indentOption: IndentOption = .spaces(count: 4), + reformatAtColumn: Int + ) { + self.isEditable = isEditable + self.isSelectable = isSelectable + self.indentOption = indentOption + self.reformatAtColumn = reformatAtColumn + } + } +} diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift new file mode 100644 index 000000000..4ce84aa4e --- /dev/null +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift @@ -0,0 +1,32 @@ +// +// EditorConfig+Layout.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/16/25. +// + +import AppKit + +extension EditorConfig { + public struct Layout: Equatable { + /// The distance to overscroll the editor by, as a multiple of the visible editor height. + public var editorOverscroll: CGFloat = 0 + + /// Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the scroll view + /// automatically adjust content insets. + public var contentInsets: NSEdgeInsets? = nil + + /// An additional amount to inset the text of the editor by. + public var additionalTextInsets: NSEdgeInsets? = nil + + public init( + editorOverscroll: CGFloat = 0, + contentInsets: NSEdgeInsets? = nil, + additionalTextInsets: NSEdgeInsets? = nil + ) { + self.editorOverscroll = editorOverscroll + self.contentInsets = contentInsets + self.additionalTextInsets = additionalTextInsets + } + } +} diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift new file mode 100644 index 000000000..93aa45798 --- /dev/null +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift @@ -0,0 +1,29 @@ +// +// EditorConfig+Peripherals.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/16/25. +// + +extension EditorConfig { + public struct Peripherals: Equatable { + /// Whether to show the gutter. + public var showGutter: Bool = true + + /// Whether to show the minimap. + public var showMinimap: Bool + + /// Whether to show the reformatting guide. + public var showReformattingGuide: Bool + + public init( + showGutter: Bool = true, + showMinimap: Bool = false, + showReformattingGuide: Bool = false + ) { + self.showGutter = showGutter + self.showMinimap = showMinimap + self.showReformattingGuide = showReformattingGuide + } + } +} diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift new file mode 100644 index 000000000..e7866db57 --- /dev/null +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift @@ -0,0 +1,27 @@ +// +// EditorConfig.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/16/25. +// + +import AppKit + +public struct EditorConfig: Equatable { + public var appearance: Appearance + public var behavior: Behavior + public var peripherals: Peripherals + public var layout: Layout + + public init( + appearance: Appearance, + behavior: Behavior, + peripherals: Peripherals = .init(), + layout: Layout = .init() + ) { + self.appearance = appearance + self.behavior = behavior + self.peripherals = peripherals + self.layout = layout + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 0d9cf5b04..22e250b14 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -95,6 +95,16 @@ public class GutterView: NSView { true } + public convenience init(config: EditorConfig, textView: TextView, delegate: GutterViewDelegate? = nil) { + self.init( + font: config.appearance.font, + textColor: config.appearance.theme.text.color, + selectedTextColor: config.appearance.theme.selection, + textView: textView, + delegate: delegate + ) + } + public init( font: NSFont, textColor: NSColor, diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index 68bbdebac..645205f5a 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -22,6 +22,14 @@ class ReformattingGuideView: NSView { } } + convenience init(config: borrowing EditorConfig) { + self.init( + column: config.behavior.reformatAtColumn, + isVisible: config.peripherals.showReformattingGuide, + theme: config.appearance.theme + ) + } + init(column: Int = 80, isVisible: Bool = false, theme: EditorTheme) { self.column = column self._isVisible = isVisible From b97f9f3d34b5523714d505fa23f7aa0c6d0fa182 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:26:47 -0500 Subject: [PATCH 02/23] Create new `EditorConfig` struct --- .../CodeEditSourceEditor.swift | 82 +---- .../TextViewController+Highlighter.swift | 2 +- .../TextViewController+LoadView.swift | 8 +- .../TextViewController+StyleViews.swift | 35 ++- .../Controller/TextViewController.swift | 294 ++++++------------ .../EditorConfig+Appearance.swift | 69 +++- .../EditorConfig/EditorConfig+Behavior.swift | 23 +- .../EditorConfig/EditorConfig+Layout.swift | 19 +- .../EditorConfig+Peripherals.swift | 27 +- .../EditorConfig/EditorConfig.swift | 16 +- .../ReformattingGuideView.swift | 42 +-- .../TextViewController+IndentTests.swift | 26 +- .../TextViewController+MoveLinesTests.swift | 8 +- .../Controller/TextViewControllerTests.swift | 80 ++--- Tests/CodeEditSourceEditorTests/Mock.swift | 38 ++- .../TagEditingTests.swift | 8 +- 16 files changed, 344 insertions(+), 433 deletions(-) diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index e1a56762b..3b9a4a3f7 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -135,88 +135,18 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { return } - updateControllerParams(controller: controller, coordinator: context.coordinator) + controller.config = config + updateHighlighting(controller, coordinator: context.coordinator) controller.reloadUI() return } - /// Update the parameters of the controller. - /// - Parameter controller: The controller to update. - func updateControllerParams(controller: TextViewController, coordinator: Coordinator) { -// updateTextProperties(controller) -// updateEditorProperties(controller) -// updateThemeAndLanguage(controller) -// updateHighlighting(controller, coordinator: coordinator) -// -// if controller.reformatAtColumn != reformatAtColumn { -// controller.reformatAtColumn = reformatAtColumn -// } -// -// if controller.showReformattingGuide != showReformattingGuide { -// controller.showReformattingGuide = showReformattingGuide -// } + private func updateHighlighting(_ controller: TextViewController, coordinator: Coordinator) { + if !areHighlightProvidersEqual(controller: controller, coordinator: coordinator) { + controller.setHighlightProviders(coordinator.highlightProviders) + } } -// -// private func updateTextProperties(_ controller: TextViewController) { -// if controller.font != font { -// controller.font = font -// } -// -// if controller.isEditable != isEditable { -// controller.isEditable = isEditable -// } -// -// if controller.isSelectable != isSelectable { -// controller.isSelectable = isSelectable -// } -// } -// -// private func updateEditorProperties(_ controller: TextViewController) { -// controller.wrapLines = wrapLines -// controller.useThemeBackground = useThemeBackground -// controller.lineHeightMultiple = lineHeight -// controller.editorOverscroll = editorOverscroll -// controller.contentInsets = contentInsets -// controller.additionalTextInsets = additionalTextInsets -// controller.showMinimap = showMinimap -// -// if controller.indentOption != indentOption { -// controller.indentOption = indentOption -// } -// -// if controller.tabWidth != tabWidth { -// controller.tabWidth = tabWidth -// } -// -// if controller.letterSpacing != letterSpacing { -// controller.letterSpacing = letterSpacing -// } -// -// if controller.useSystemCursor != useSystemCursor { -// controller.useSystemCursor = useSystemCursor -// } -// } -// -// private func updateThemeAndLanguage(_ controller: TextViewController) { -// if controller.language.id != language.id { -// controller.language = language -// } -// -// if controller.theme != theme { -// controller.theme = theme -// } -// } -// -// private func updateHighlighting(_ controller: TextViewController, coordinator: Coordinator) { -// if !areHighlightProvidersEqual(controller: controller, coordinator: coordinator) { -// controller.setHighlightProviders(coordinator.highlightProviders) -// } -// -// if controller.bracketPairEmphasis != bracketPairEmphasis { -// controller.bracketPairEmphasis = bracketPairEmphasis -// } -// } /// Checks if the controller needs updating. /// - Parameter controller: The controller to check. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift index d96ca1bc0..50f221c4e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -41,7 +41,7 @@ extension TextViewController { extension TextViewController: ThemeAttributesProviding { public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { [ - .font: config.appearance.theme.fontFor(for: capture, from: config.appearance.font), + .font: config.appearance.theme.fontFor(for: capture, from: font), .foregroundColor: config.appearance.theme.colorFor(capture), .kern: textView.kern ] diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index f3f29ab56..609e6da78 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -23,10 +23,8 @@ extension TextViewController { gutterView.updateWidthIfNeeded() scrollView.addFloatingSubview(gutterView, for: .horizontal) - guideView = ReformattingGuideView(config: config) - guideView.wantsLayer = true - scrollView.addFloatingSubview(guideView, for: .vertical) - guideView.updatePosition(in: textView) + reformattingGuideView = ReformattingGuideView(config: config) + scrollView.addFloatingSubview(reformattingGuideView, for: .vertical) minimapView = MinimapView(textView: textView, theme: config.appearance.theme) scrollView.addFloatingSubview(minimapView, for: .vertical) @@ -130,7 +128,7 @@ extension TextViewController { - (self?.scrollView.contentInsets.top ?? 0) self?.gutterView.needsDisplay = true - self?.guideView?.updatePosition(in: textView) + self?.reformattingGuideView?.updatePosition(in: textView) self?.scrollView.needsLayout = true } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index e42be22cc..a05eab7d4 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -13,7 +13,7 @@ extension TextViewController { // swiftlint:disable:next force_cast let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle paragraph.tabStops.removeAll() - paragraph.defaultTabInterval = CGFloat(config.appearance.tabWidth) * fontCharWidth + paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth return paragraph } @@ -21,15 +21,16 @@ extension TextViewController { package func styleTextView() { textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false - textView.selectionManager.selectionBackgroundColor = config.appearance.theme.selection + textView.selectionManager.selectionBackgroundColor = theme.selection textView.selectionManager.selectedLineBackgroundColor = getThemeBackground() textView.selectionManager.highlightSelectedLine = config.behavior.isEditable - textView.selectionManager.insertionPointColor = config.appearance.theme.insertionPoint - textView.enclosingScrollView?.backgroundColor = if config.appearance.useThemeBackground { - config.appearance.theme.background + textView.selectionManager.insertionPointColor = theme.insertionPoint + textView.enclosingScrollView?.backgroundColor = if useThemeBackground { + theme.background } else { .clear } + textView.overscrollAmount = editorOverscroll paragraphStyle = generateParagraphStyle() textView.typingAttributes = attributesFor(nil) } @@ -37,8 +38,8 @@ extension TextViewController { /// Finds the preferred use theme background. /// - Returns: The background color to use. private func getThemeBackground() -> NSColor { - if config.appearance.useThemeBackground { - return config.appearance.theme.lineHighlight + if useThemeBackground { + return theme.lineHighlight } if systemAppearance == .darkAqua { @@ -50,17 +51,17 @@ extension TextViewController { /// Style the gutter view. package func styleGutterView() { - gutterView.selectedLineColor = if config.appearance.useThemeBackground { - config.appearance.theme.lineHighlight + gutterView.selectedLineColor = if useThemeBackground { + theme.lineHighlight } else if systemAppearance == .darkAqua { NSColor.quaternaryLabelColor } else { NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) } gutterView.highlightSelectedLines = config.behavior.isEditable - gutterView.font = config.appearance.font.rulerFont - gutterView.backgroundColor = if config.appearance.useThemeBackground { - config.appearance.theme.background + gutterView.font = font.rulerFont + gutterView.backgroundColor = if useThemeBackground { + theme.background } else { .windowBackgroundColor } @@ -68,6 +69,7 @@ extension TextViewController { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear } + gutterView.isHidden = !showGutter } /// Style the scroll view. @@ -75,13 +77,18 @@ extension TextViewController { scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.contentView.postsFrameChangedNotifications = true scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = !config.appearance.wrapLines + scrollView.hasHorizontalScroller = !wrapLines scrollView.scrollerStyle = .overlay } package func styleMinimapView() { minimapView.postsFrameChangedNotifications = true - minimapView.isHidden = !config.peripherals.showMinimap + minimapView.isHidden = !showMinimap + } + + package func styleReformattingGuideView() { + reformattingGuideView.updatePosition(in: textView) + reformattingGuideView.isHidden = !showReformattingGuide } /// Updates all relevant content insets including the find panel, scroll view, minimap and gutter position. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index a8f16ca79..f58ca070e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -13,13 +13,15 @@ import Combine import TextFormation /// # TextViewController -/// +/// /// A view controller class for managing a source editor. Uses ``CodeEditTextView/TextView`` for input and rendering, /// tree-sitter for syntax highlighting, and TextFormation for live editing completions. public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + // MARK: - Views and Child VCs + weak var findViewController: FindViewController? var scrollView: NSScrollView! @@ -28,13 +30,7 @@ public class TextViewController: NSViewController { var minimapView: MinimapView! /// The reformatting guide view - var guideView: ReformattingGuideView! { - didSet { - if let oldValue = oldValue { - oldValue.removeFromSuperview() - } - } - } + var reformattingGuideView: ReformattingGuideView! var minimapXConstraint: NSLayoutConstraint? @@ -44,9 +40,16 @@ public class TextViewController: NSViewController { var localEvenMonitor: Any? var isPostingCursorNotification: Bool = false - /// The string contents. - public var string: String { - textView.string + // MARK: - Public Variables + + /// Passthrough value for the `textView`s string + public var text: String { + get { + textView.string + } + set { + self.setText(newValue) + } } /// The associated `CodeLanguage` @@ -57,163 +60,87 @@ public class TextViewController: NSViewController { } } - public var config: EditorConfig - -// /// The font to use in the `textView` -// public var font: NSFont { -// didSet { -// textView.font = font -// highlighter?.invalidate() -// } -// } -// -// /// The associated `Theme` used for highlighting. -// public var theme: EditorTheme { -// didSet { -// textView.layoutManager.setNeedsLayout() -// textView.textStorage.setAttributes( -// attributesFor(nil), -// range: NSRange(location: 0, length: textView.textStorage.length) -// ) -// textView.selectionManager.selectedLineBackgroundColor = theme.selection -// highlighter?.invalidate() -// gutterView.textColor = theme.text.color.withAlphaComponent(0.35) -// gutterView.selectedLineTextColor = theme.text.color -// minimapView.setTheme(theme) -// guideView?.setTheme(theme) -// } -// } -// -// /// The visual width of tab characters in the text view measured in number of spaces. -// public var tabWidth: Int { -// didSet { -// paragraphStyle = generateParagraphStyle() -// textView.layoutManager.setNeedsLayout() -// highlighter?.invalidate() -// } -// } -// -// /// The behavior to use when the tab key is pressed. -// public var indentOption: IndentOption { -// didSet { -// setUpTextFormation() -// } -// } -// -// /// A multiplier for setting the line height. Defaults to `1.0` -// public var lineHeightMultiple: CGFloat { -// didSet { -// textView.layoutManager.lineHeightMultiplier = lineHeightMultiple -// } -// } -// -// /// Whether lines wrap to the width of the editor -// public var wrapLines: Bool { -// didSet { -// textView.layoutManager.wrapLines = wrapLines -// minimapView.layoutManager?.wrapLines = wrapLines -// scrollView.hasHorizontalScroller = !wrapLines -// updateTextInsets() -// } -// } + /// The configuration for the editor, when updated will automatically update the controller to reflect the new + /// configuration. + public var config: EditorConfig { + didSet { + config.didSetOnController(controller: self, oldConfig: oldValue) + } + } /// The current cursors' positions ordered by the location of the cursor. internal(set) public var cursorPositions: [CursorPosition] = [] -// /// The editorOverscroll to use for the textView over scroll -// /// -// /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll -// /// of 1/3 of the view. -// public var editorOverscroll: CGFloat { -// didSet { -// textView.overscrollAmount = editorOverscroll -// } -// } -// -// /// Whether the code editor should use the theme background color or be transparent -// public var useThemeBackground: Bool - /// The provided highlight provider. public var highlightProviders: [HighlightProviding] -// /// Optional insets to offset the text view and find panel in the scroll view by. -// public var contentInsets: NSEdgeInsets? { -// didSet { -// updateContentInsets() -// } -// } -// -// /// An additional amount to inset text by. Horizontal values are ignored. -// /// -// /// This value does not affect decorations like the find panel, but affects things that are relative to text, such -// /// as line numbers and of course the text itself. -// public var additionalTextInsets: NSEdgeInsets? { -// didSet { -// styleScrollView() -// } -// } -// -// /// Whether or not text view is editable by user -// public var isEditable: Bool { -// didSet { -// textView.isEditable = isEditable -// } -// } -// -// /// Whether or not text view is selectable by user -// public var isSelectable: Bool { -// didSet { -// textView.isSelectable = isSelectable -// } -// } -// -// /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, -// /// `2.0` indicates one character of space between other characters. -// public var letterSpacing: Double = 1.0 { -// didSet { -// textView.letterSpacing = letterSpacing -// highlighter?.invalidate() -// } -// } -// -// /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. -// public var bracketPairEmphasis: BracketPairEmphasis? { -// didSet { -// emphasizeSelectionPairs() -// } -// } + // MARK: - Config Helpers - /// Passthrough value for the `textView`s string - public var text: String { - get { - textView.string - } - set { - self.setText(newValue) - } - } + /// The font to use in the `textView` + public var font: NSFont { config.appearance.font } -// /// If true, uses the system cursor on macOS 14 or greater. -// public var useSystemCursor: Bool { -// get { -// textView.useSystemCursor -// } -// set { -// if #available(macOS 14, *) { -// textView.useSystemCursor = newValue -// } -// } -// } -// -// public var showMinimap: Bool { -// didSet { -// minimapView?.isHidden = !showMinimap -// if scrollView != nil { // Check for view existence -// updateContentInsets() -// updateTextInsets() -// } -// } -// } + /// The ``EditorTheme`` used for highlighting. + public var theme: EditorTheme { config.appearance.theme } + + /// The visual width of tab characters in the text view measured in number of spaces. + public var tabWidth: Int { config.appearance.tabWidth } + + /// The behavior to use when the tab key is pressed. + public var indentOption: IndentOption { config.behavior.indentOption } + + /// A multiplier for setting the line height. Defaults to `1.0` + public var lineHeightMultiple: CGFloat { config.appearance.lineHeightMultiple } + + /// Whether lines wrap to the width of the editor + public var wrapLines: Bool { config.appearance.wrapLines } + + /// The editorOverscroll to use for the textView over scroll + /// + /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll + /// of 1/3 of the view. + public var editorOverscroll: CGFloat { config.layout.editorOverscroll } + + /// Whether the code editor should use the theme background color or be transparent + public var useThemeBackground: Bool { config.appearance.useThemeBackground } + + /// Optional insets to offset the text view and find panel in the scroll view by. + public var contentInsets: NSEdgeInsets? { config.layout.contentInsets } + + /// An additional amount to inset text by. Horizontal values are ignored. + /// + /// This value does not affect decorations like the find panel, but affects things that are relative to text, such + /// as line numbers and of course the text itself. + public var additionalTextInsets: NSEdgeInsets? { config.layout.additionalTextInsets } + + /// Whether or not text view is editable by user + public var isEditable: Bool { config.behavior.isEditable } + + /// Whether or not text view is selectable by user + public var isSelectable: Bool { config.behavior.isSelectable } + + /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, + /// `2.0` indicates one character of space between other characters. + public var letterSpacing: Double { config.appearance.letterSpacing } + + /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. + public var bracketPairEmphasis: BracketPairEmphasis? { config.appearance.bracketPairEmphasis } + + /// The column at which to show the reformatting guide + public var reformatAtColumn: Int { config.behavior.reformatAtColumn } + + /// If true, uses the system cursor on macOS 14 or greater. + public var useSystemCursor: Bool { config.appearance.useSystemCursor } + + /// Toggle the visibility of the gutter view in the editor. + public var showGutter: Bool { config.peripherals.showGutter } + + /// Toggle the visibility of the minimap view in the editor. + public var showMinimap: Bool { config.peripherals.showMinimap } + + /// Toggle the visibility of the reformatting guide in the editor. + public var showReformattingGuide: Bool { config.peripherals.showReformattingGuide } + + // MARK: - Internal Variables var textCoordinators: [WeakCoordinator] = [] @@ -224,7 +151,7 @@ public class TextViewController: NSViewController { /// This will be `nil` if another highlighter provider is passed to the source editor. internal(set) public var treeSitterClient: TreeSitterClient? - package var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: config.appearance.font]).width } + package var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } /// Filters used when applying edits.. internal var textFilters: [TextFormation.Filter] = [] @@ -240,33 +167,11 @@ public class TextViewController: NSViewController { package var textViewInsets: HorizontalEdgeInsets { HorizontalEdgeInsets( - left: gutterView.gutterWidth, + left: showGutter ? gutterView.gutterWidth : 0.0, right: textViewTrailingInset ) } -// /// The column at which to show the reformatting guide -// public var reformatAtColumn: Int = 80 { -// didSet { -// if let guideView = self.guideView { -// guideView.setColumn(reformatAtColumn) -// guideView.updatePosition(in: textView) -// guideView.needsDisplay = true -// } -// } -// } -// -// /// Whether to show the reformatting guide -// public var showReformattingGuide: Bool = false { -// didSet { -// if let guideView = self.guideView { -// guideView.setVisible(showReformattingGuide) -// guideView.updatePosition(in: textView) -// guideView.needsDisplay = true -// } -// } -// } - // MARK: Init init( @@ -293,24 +198,17 @@ public class TextViewController: NSViewController { self.textView = TextView( string: string, - font: config.appearance.font, - textColor: config.appearance.theme.text.color, - lineHeightMultiplier: config.appearance.lineHeight, - wrapLines: config.appearance.wrapLines, - isEditable: config.behavior.isEditable, - isSelectable: config.behavior.isSelectable, - letterSpacing: config.appearance.letterSpacing, - useSystemCursor: config.appearance.useSystemCursor, + font: font, + textColor: theme.text.color, + lineHeightMultiplier: lineHeightMultiple, + wrapLines: wrapLines, + isEditable: isEditable, + isSelectable: isSelectable, + letterSpacing: letterSpacing, + useSystemCursor: useSystemCursor, delegate: self ) - // Initialize guide view - self.guideView = ReformattingGuideView( - column: config.behavior.reformatAtColumn, - isVisible: config.peripherals.showReformattingGuide, - theme: config.appearance.theme - ) - coordinators.forEach { $0.prepareCoordinator(controller: self) } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift index 7b6f3ec48..bcf0148f4 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift @@ -19,7 +19,7 @@ extension EditorConfig { public var font: NSFont /// The line height multiplier (e.g. `1.2`). - public var lineHeight: Double + public var lineHeightMultiple: Double /// The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a /// character's width between characters, etc. Defaults to `1.0`. @@ -42,7 +42,7 @@ extension EditorConfig { theme: EditorTheme, useThemeBackground: Bool = true, font: NSFont, - lineHeight: Double, + lineHeightMultiple: Double, letterSpacing: Double = 1.0, wrapLines: Bool, useSystemCursor: Bool = true, @@ -52,7 +52,7 @@ extension EditorConfig { self.theme = theme self.useThemeBackground = useThemeBackground self.font = font - self.lineHeight = lineHeight + self.lineHeightMultiple = lineHeightMultiple self.letterSpacing = letterSpacing self.wrapLines = wrapLines if #available(macOS 14, *) { @@ -63,5 +63,68 @@ extension EditorConfig { self.tabWidth = tabWidth self.bracketPairEmphasis = bracketPairEmphasis } + + @MainActor + func didSetOnController(controller: TextViewController, oldConfig: Appearance) { + var needsHighlighterInvalidation = false + + if oldConfig.font != font { + controller.textView.font = font + needsHighlighterInvalidation = true + } + + if oldConfig.theme != theme { + controller.textView.layoutManager.setNeedsLayout() + controller.textView.textStorage.setAttributes( + controller.attributesFor(nil), + range: NSRange(location: 0, length: controller.textView.textStorage.length) + ) + controller.textView.selectionManager.selectedLineBackgroundColor = theme.selection + controller.gutterView.textColor = theme.text.color.withAlphaComponent(0.35) + controller.gutterView.selectedLineTextColor = theme.text.color + controller.minimapView.setTheme(theme) + controller.reformattingGuideView?.theme = theme + needsHighlighterInvalidation = true + } + + if oldConfig.tabWidth != tabWidth { + controller.paragraphStyle = controller.generateParagraphStyle() + controller.textView.layoutManager.setNeedsLayout() + needsHighlighterInvalidation = true + } + + if oldConfig.lineHeightMultiple != lineHeightMultiple { + controller.textView.layoutManager.lineHeightMultiplier = lineHeightMultiple + } + + if oldConfig.wrapLines != wrapLines { + controller.textView.layoutManager.wrapLines = wrapLines + controller.minimapView.layoutManager?.wrapLines = wrapLines + controller.scrollView.hasHorizontalScroller = !wrapLines + controller.updateTextInsets() + } + + // useThemeBackground isn't needed + + if oldConfig.letterSpacing != letterSpacing { + controller.textView.letterSpacing = letterSpacing + needsHighlighterInvalidation = true + } + + if oldConfig.bracketPairEmphasis != bracketPairEmphasis { + controller.emphasizeSelectionPairs() + } + + // Cant put these in one if sadly + if #available(macOS 14, *) { + if oldConfig.useSystemCursor != useSystemCursor { + controller.textView.useSystemCursor = useSystemCursor + } + } + + if needsHighlighterInvalidation { + controller.highlighter?.invalidate() + } + } } } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift index 6b6b5e854..befb8e516 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift @@ -18,18 +18,37 @@ extension EditorConfig { public var indentOption: IndentOption = .spaces(count: 4) /// The column to reformat at. - public var reformatAtColumn: Int + public var reformatAtColumn: Int = 80 public init( isEditable: Bool = true, isSelectable: Bool = true, indentOption: IndentOption = .spaces(count: 4), - reformatAtColumn: Int + reformatAtColumn: Int = 80 ) { self.isEditable = isEditable self.isSelectable = isSelectable self.indentOption = indentOption self.reformatAtColumn = reformatAtColumn } + + @MainActor + func didSetOnController(controller: TextViewController, oldConfig: Behavior) { + if oldConfig.isEditable != isEditable { + controller.textView.isEditable = isEditable + } + + if oldConfig.isSelectable != isSelectable { + controller.textView.isSelectable = isSelectable + } + + if oldConfig.indentOption != indentOption { + controller.setUpTextFormation() + } + + if oldConfig.reformatAtColumn != reformatAtColumn { + controller.reformattingGuideView.column = reformatAtColumn + } + } } } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift index 4ce84aa4e..2bead02cd 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift @@ -14,10 +14,10 @@ extension EditorConfig { /// Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the scroll view /// automatically adjust content insets. - public var contentInsets: NSEdgeInsets? = nil + public var contentInsets: NSEdgeInsets? /// An additional amount to inset the text of the editor by. - public var additionalTextInsets: NSEdgeInsets? = nil + public var additionalTextInsets: NSEdgeInsets? public init( editorOverscroll: CGFloat = 0, @@ -28,5 +28,20 @@ extension EditorConfig { self.contentInsets = contentInsets self.additionalTextInsets = additionalTextInsets } + + @MainActor + func didSetOnController(controller: TextViewController, oldConfig: Layout) { + if oldConfig.editorOverscroll != editorOverscroll { + controller.textView.overscrollAmount = editorOverscroll + } + + if oldConfig.contentInsets != contentInsets { + controller.updateContentInsets() + } + + if oldConfig.additionalTextInsets != additionalTextInsets { + controller.styleScrollView() + } + } } } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift index 93aa45798..61051be5b 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift @@ -18,12 +18,37 @@ extension EditorConfig { public init( showGutter: Bool = true, - showMinimap: Bool = false, + showMinimap: Bool = true, showReformattingGuide: Bool = false ) { self.showGutter = showGutter self.showMinimap = showMinimap self.showReformattingGuide = showReformattingGuide } + + @MainActor + func didSetOnController(controller: TextViewController, oldConfig: Peripherals) { + var shouldUpdateInsets = false + + if oldConfig.showGutter != showGutter { + controller.gutterView.isHidden = !showGutter + shouldUpdateInsets = true + } + + if oldConfig.showMinimap != showMinimap { + controller.minimapView?.isHidden = !showMinimap + shouldUpdateInsets = true + } + + if oldConfig.showReformattingGuide != showReformattingGuide { + controller.reformattingGuideView.isHidden = !showReformattingGuide + controller.reformattingGuideView.updatePosition(in: controller.textView) + } + + if shouldUpdateInsets && controller.scrollView != nil { // Check for view existence + controller.updateContentInsets() + controller.updateTextInsets() + } + } } } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift index e7866db57..d677e42bc 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift +++ b/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift @@ -15,13 +15,21 @@ public struct EditorConfig: Equatable { public init( appearance: Appearance, - behavior: Behavior, - peripherals: Peripherals = .init(), - layout: Layout = .init() + behavior: Behavior = .init(), + layout: Layout = .init(), + peripherals: Peripherals = .init() ) { self.appearance = appearance self.behavior = behavior - self.peripherals = peripherals self.layout = layout + self.peripherals = peripherals + } + + @MainActor + func didSetOnController(controller: TextViewController, oldConfig: EditorConfig) { + appearance.didSetOnController(controller: controller, oldConfig: oldConfig.appearance) + behavior.didSetOnController(controller: controller, oldConfig: oldConfig.behavior) + layout.didSetOnController(controller: controller, oldConfig: oldConfig.layout) + peripherals.didSetOnController(controller: controller, oldConfig: oldConfig.peripherals) } } diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index 645205f5a..f43d84613 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -9,34 +9,25 @@ import AppKit import CodeEditTextView class ReformattingGuideView: NSView { - private var column: Int - private var _isVisible: Bool - private var theme: EditorTheme - - var isVisible: Bool { - get { _isVisible } - set { - _isVisible = newValue - isHidden = !newValue - needsDisplay = true - } + @Invalidating(.display) + var column: Int = 80 + + var theme: EditorTheme { + didSet { needsDisplay = true } } convenience init(config: borrowing EditorConfig) { self.init( column: config.behavior.reformatAtColumn, - isVisible: config.peripherals.showReformattingGuide, theme: config.appearance.theme ) } - init(column: Int = 80, isVisible: Bool = false, theme: EditorTheme) { + init(column: Int = 80, theme: EditorTheme) { self.column = column - self._isVisible = isVisible self.theme = theme super.init(frame: .zero) wantsLayer = true - isHidden = !isVisible } required init?(coder: NSCoder) { @@ -50,9 +41,6 @@ class ReformattingGuideView: NSView { // Draw the reformatting guide line and shaded area override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) - guard isVisible else { - return - } // Determine if we should use light or dark colors based on the theme's background color let isLightMode = theme.background.brightnessComponent > 0.5 @@ -87,10 +75,6 @@ class ReformattingGuideView: NSView { } func updatePosition(in textView: TextView) { - guard isVisible else { - return - } - // Calculate the x position based on the font's character width and column number let charWidth = textView.font.boundingRectForFont.width let xPosition = CGFloat(column) * charWidth / 2 // Divide by 2 to account for coordinate system @@ -113,18 +97,4 @@ class ReformattingGuideView: NSView { frame = newFrame needsDisplay = true } - - func setVisible(_ visible: Bool) { - isVisible = visible - } - - func setColumn(_ newColumn: Int) { - column = newColumn - needsDisplay = true - } - - func setTheme(_ newTheme: EditorTheme) { - theme = newTheme - needsDisplay = true - } } diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift index 319043c11..133b13e0f 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift @@ -26,7 +26,7 @@ final class TextViewControllerIndentTests: XCTestCase { controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.handleIndent(inwards: true) - expectNoDifference(controller.string, "This is a test string") + expectNoDifference(controller.text, "This is a test string") // Normally, 4 spaces are used for indentation; however, now we only insert 2 leading spaces. // The outcome should be the same, though. @@ -35,7 +35,7 @@ final class TextViewControllerIndentTests: XCTestCase { controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.handleIndent(inwards: true) - expectNoDifference(controller.string, "This is a test string") + expectNoDifference(controller.text, "This is a test string") } func testHandleIndentWithSpacesOutwards() { @@ -46,24 +46,24 @@ final class TextViewControllerIndentTests: XCTestCase { controller.handleIndent(inwards: false) - expectNoDifference(controller.string, " This is a test string") + expectNoDifference(controller.text, " This is a test string") } func testHandleIndentWithTabsInwards() { controller.setText("\tThis is a test string") - controller.indentOption = .tab + controller.config.behavior .indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions controller.handleIndent(inwards: true) - expectNoDifference(controller.string, "This is a test string") + expectNoDifference(controller.text, "This is a test string") } func testHandleIndentWithTabsOutwards() { controller.setText("This is a test string") - controller.indentOption = .tab + controller.config.behavior.indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions @@ -72,11 +72,11 @@ final class TextViewControllerIndentTests: XCTestCase { // Normally, we expect nothing to happen because only one line is selected. // However, this logic is not handled inside `handleIndent`. - expectNoDifference(controller.string, "\tThis is a test string") + expectNoDifference(controller.text, "\tThis is a test string") } func testHandleIndentMultiLine() { - controller.indentOption = .tab + controller.config.behavior.indentOption = .tab let strings: [(NSString, Int)] = [ ("This is a test string\n", 0), ("With multiple lines\n", 22), @@ -95,11 +95,11 @@ final class TextViewControllerIndentTests: XCTestCase { controller.handleIndent() let expectedString = "\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation" - expectNoDifference(controller.string, expectedString) + expectNoDifference(controller.text, expectedString) } func testHandleInwardIndentMultiLine() { - controller.indentOption = .tab + controller.config.behavior.indentOption = .tab let strings: [(NSString, NSRange)] = [ ("\tThis is a test string\n", NSRange(location: 0, length: 0)), ("\tWith multiple lines\n", NSRange(location: 23, length: 0)), @@ -112,18 +112,18 @@ final class TextViewControllerIndentTests: XCTestCase { ) } - let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))] + let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.text.count))] controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 62))] controller.cursorPositions = cursorPositions controller.handleIndent(inwards: true) let expectedString = "This is a test string\nWith multiple lines\nAnd some indentation" - expectNoDifference(controller.string, expectedString) + expectNoDifference(controller.text, expectedString) } func testMultipleLinesHighlighted() { controller.setText("\tThis is a test string\n\tWith multiple lines\n\tAnd some indentation") - var cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.string.count))] + var cursorPositions = [CursorPosition(range: NSRange(location: 0, length: controller.text.count))] controller.cursorPositions = cursorPositions XCTAssert(controller.multipleLinesHighlighted()) diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+MoveLinesTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+MoveLinesTests.swift index 738a647b3..82ef656a8 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+MoveLinesTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+MoveLinesTests.swift @@ -37,7 +37,7 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesUp() let expectedString = "With multiple lines\nThis is a test string\n" - expectNoDifference(controller.string, expectedString) + expectNoDifference(controller.text, expectedString) } func testHandleMoveLinesDownForSingleLine() { @@ -58,7 +58,7 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesDown() let expectedString = "With multiple lines\nThis is a test string\n" - expectNoDifference(controller.string, expectedString) + expectNoDifference(controller.text, expectedString) } func testHandleMoveLinesUpForMultiLine() { @@ -80,7 +80,7 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesUp() let expectedString = "With multiple lines\nAnd additional info\nThis is a test string\n" - expectNoDifference(controller.string, expectedString) + expectNoDifference(controller.text, expectedString) } func testHandleMoveLinesDownForMultiLine() { @@ -102,6 +102,6 @@ final class TextViewControllerMoveLinesTests: XCTestCase { controller.moveLinesDown() let expectedString = "And additional info\nThis is a test string\nWith multiple lines\n" - expectNoDifference(controller.string, expectedString) + expectNoDifference(controller.text, expectedString) } } diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index abac077dc..b1cc6599a 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -13,27 +13,7 @@ final class TextViewControllerTests: XCTestCase { override func setUpWithError() throws { theme = Mock.theme() - controller = TextViewController( - string: "", - language: .default, - font: .monospacedSystemFont(ofSize: 11, weight: .medium), - theme: theme, - tabWidth: 4, - indentOption: .spaces(count: 4), - lineHeight: 1.0, - wrapLines: true, - cursorPositions: [], - editorOverscroll: 0.5, - useThemeBackground: true, - highlightProviders: [], - contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), - isEditable: true, - isSelectable: true, - letterSpacing: 1.0, - useSystemCursor: false, - bracketPairEmphasis: .flash, - showMinimap: true - ) + controller = Mock.textViewController(theme: theme) controller.loadView() controller.view.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) @@ -69,17 +49,17 @@ final class TextViewControllerTests: XCTestCase { // MARK: Overscroll func test_editorOverScroll() throws { - controller.editorOverscroll = 0 + controller.config.layout.editorOverscroll = 0 // editorOverscroll: 0 XCTAssertEqual(controller.textView.overscrollAmount, 0) - controller.editorOverscroll = 0.5 + controller.config.layout.editorOverscroll = 0.5 // editorOverscroll: 0.5 XCTAssertEqual(controller.textView.overscrollAmount, 0.5) - controller.editorOverscroll = 1.0 + controller.config.layout.editorOverscroll = 1.0 XCTAssertEqual(controller.textView.overscrollAmount, 1.0) } @@ -102,8 +82,8 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(lhs.left, rhs.left) } - controller.editorOverscroll = 0 - controller.contentInsets = nil + controller.config.layout.editorOverscroll = 0 + controller.config.layout.contentInsets = nil controller.reloadUI() // contentInsets: 0 @@ -111,14 +91,14 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.gutterView.frame.origin.y, 0) // contentInsets: 16 - controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.config.layout.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) controller.reloadUI() try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) XCTAssertEqual(controller.gutterView.frame.origin.y, -16) // contentInsets: different - controller.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) + controller.config.layout.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) controller.reloadUI() try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) @@ -126,8 +106,8 @@ final class TextViewControllerTests: XCTestCase { // contentInsets: 16 // editorOverscroll: 0.5 - controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) - controller.editorOverscroll = 0.5 // Should be ignored + controller.config.layout.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.config.layout.editorOverscroll = 0.5 // Should be ignored controller.reloadUI() try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) @@ -150,14 +130,14 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(lhs.left, rhs.left) } - controller.contentInsets = nil - controller.additionalTextInsets = nil + controller.config.layout.contentInsets = nil + controller.config.layout.additionalTextInsets = nil try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) XCTAssertEqual(controller.gutterView.frame.origin.y, 0) - controller.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) - controller.additionalTextInsets = NSEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) + controller.config.layout.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + controller.config.layout.additionalTextInsets = NSEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) controller.findViewController?.showFindPanel(animated: false) @@ -195,38 +175,38 @@ final class TextViewControllerTests: XCTestCase { controller.highlighter = nil // Insert 1 space - controller.indentOption = .spaces(count: 1) + controller.config.behavior.indentOption = .spaces(count: 1) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 2 spaces - controller.indentOption = .spaces(count: 2) + controller.config.behavior.indentOption = .spaces(count: 2) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 3 spaces - controller.indentOption = .spaces(count: 3) + controller.config.behavior.indentOption = .spaces(count: 3) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 4 spaces - controller.indentOption = .spaces(count: 4) + controller.config.behavior.indentOption = .spaces(count: 4) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert tab - controller.indentOption = .tab + controller.config.behavior.indentOption = .tab controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, "\t") // Insert lots of spaces - controller.indentOption = .spaces(count: 1000) + controller.config.behavior.indentOption = .spaces(count: 1000) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) @@ -235,20 +215,20 @@ final class TextViewControllerTests: XCTestCase { func test_letterSpacing() { let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) - controller.letterSpacing = 1.0 + controller.config.appearance.letterSpacing = 1.0 XCTAssertEqual( controller.attributesFor(nil)[.kern]! as! CGFloat, (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 ) - controller.letterSpacing = 2.0 + controller.config.appearance.letterSpacing = 2.0 XCTAssertEqual( controller.attributesFor(nil)[.kern]! as! CGFloat, (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 ) - controller.letterSpacing = 1.0 + controller.config.appearance.letterSpacing = 1.0 } // MARK: Bracket Highlights @@ -261,27 +241,27 @@ final class TextViewControllerTests: XCTestCase { controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() let _ = controller.textView.becomeFirstResponder() - controller.bracketPairEmphasis = nil + controller.config.appearance.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 0, "Controller added bracket emphasis when setting is set to `nil`") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - controller.bracketPairEmphasis = .bordered(color: .black) + controller.config.appearance.bracketPairEmphasis = .bordered(color: .black) controller.textView.setNeedsDisplay() controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for bordered.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") - controller.bracketPairEmphasis = .underline(color: .black) + controller.config.appearance.bracketPairEmphasis = .underline(color: .black) controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for underline.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") - controller.bracketPairEmphasis = .flash + controller.config.appearance.bracketPairEmphasis = .flash controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 1, "Controller created more than one emphasis for flash animation.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) @@ -341,7 +321,7 @@ final class TextViewControllerTests: XCTestCase { controller.setText("\nHello World with newline!") - XCTAssert(controller.string == "\nHello World with newline!") + XCTAssert(controller.text == "\nHello World with newline!") XCTAssertEqual(controller.cursorPositions.count, 1) XCTAssertEqual(controller.cursorPositions[0].line, 2) XCTAssertEqual(controller.cursorPositions[0].column, 1) @@ -452,11 +432,11 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) - controller.showMinimap = false + controller.config.peripherals.showMinimap = false XCTAssertTrue(controller.minimapView.isHidden) XCTAssertEqual(controller.textViewInsets.right, 0) - controller.showMinimap = true + controller.config.peripherals.showMinimap = true XCTAssertFalse(controller.minimapView.isHidden) XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 1eb96c0c4..b32db1b12 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -44,38 +44,36 @@ class MockHighlightProvider: HighlightProviding { enum Mock { class Delegate: TextViewDelegate { } + static func config() -> EditorConfig { + EditorConfig( + appearance: .init( + theme: theme(), + font: .monospacedSystemFont(ofSize: 11, weight: .medium), + lineHeightMultiple: 1.0, + wrapLines: true, + tabWidth: 4 + ) + ) + } + static func textViewController(theme: EditorTheme) -> TextViewController { TextViewController( string: "", language: .html, - font: .monospacedSystemFont(ofSize: 11, weight: .medium), - theme: theme, - tabWidth: 4, - indentOption: .spaces(count: 4), - lineHeight: 1.0, - wrapLines: true, + config: config(), cursorPositions: [], - editorOverscroll: 0.5, - useThemeBackground: true, - highlightProviders: [TreeSitterClient()], - contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), - isEditable: true, - isSelectable: true, - letterSpacing: 1.0, - useSystemCursor: false, - bracketPairEmphasis: .flash, - showMinimap: true + highlightProviders: [TreeSitterClient()] ) } static func theme() -> EditorTheme { EditorTheme( text: EditorTheme.Attribute(color: .textColor), - insertionPoint: .textColor, + insertionPoint: .textColor.usingColorSpace(.deviceRGB) ?? .black, invisibles: EditorTheme.Attribute(color: .gray), - background: .textBackgroundColor, - lineHighlight: .highlightColor, - selection: .selectedTextColor, + background: .textBackgroundColor.usingColorSpace(.deviceRGB) ?? .black, + lineHighlight: .highlightColor.usingColorSpace(.deviceRGB) ?? .black, + selection: .selectedTextColor.usingColorSpace(.deviceRGB) ?? .black, keywords: EditorTheme.Attribute(color: .systemPink), commands: EditorTheme.Attribute(color: .systemBlue), types: EditorTheme.Attribute(color: .systemMint), diff --git a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift index 706f7e14e..363cb9133 100644 --- a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift +++ b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift @@ -44,7 +44,7 @@ final class TagEditingTests: XCTestCase { } func test_tagCloseWithNewline() { - controller.indentOption = .spaces(count: 4) + controller.config.behavior.indentOption = .spaces(count: 4) controller.setText("\n
") controller.textView.selectionManager.setSelectedRange(NSRange(location: 21, length: 0)) @@ -58,7 +58,7 @@ final class TagEditingTests: XCTestCase { } func test_nestedClose() { - controller.indentOption = .spaces(count: 4) + controller.config.behavior.indentOption = .spaces(count: 4) controller.setText("\n
\n
\n
\n") controller.textView.selectionManager.setSelectedRange(NSRange(location: 30, length: 0)) @@ -74,7 +74,7 @@ final class TagEditingTests: XCTestCase { } func test_tagNotClose() { - controller.indentOption = .spaces(count: 1) + controller.config.behavior.indentOption = .spaces(count: 1) controller.setText("\n
\n
\n
\n") controller.textView.selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) @@ -127,7 +127,7 @@ final class TagEditingTests: XCTestCase { func test_TSXTagClose() { controller.language = .tsx - controller.indentOption = .spaces(count: 4) + controller.config.behavior.indentOption = .spaces(count: 4) controller.setText(""" const name = "CodeEdit" const element = ( From 091fc3400cef39966e3f287c54eed93597d73510 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 13:59:26 -0500 Subject: [PATCH 03/23] Rename to `SourceEditor`, `SourceEditorConfiguration` --- .../Views/ContentView.swift | 73 +++++++++++++------ .../Views/StatusBar.swift | 2 + .../TextViewController+LoadView.swift | 2 + .../Controller/TextViewController.swift | 4 +- .../Documentation.docc/Documentation.md | 2 +- .../Gutter/GutterView.swift | 8 +- .../ReformattingGuideView.swift | 2 +- .../SourceEditor+Coordinator.swift} | 4 +- .../SourceEditor.swift} | 54 +++----------- ...ourceEditorConfiguration+Appearance.swift} | 26 +++---- .../SourceEditorConfiguration+Behavior.swift} | 15 ++-- .../SourceEditorConfiguration+Layout.swift} | 14 ++-- ...urceEditorConfiguration+Peripherals.swift} | 10 +-- .../SourceEditorConfiguration.swift} | 14 ++-- .../TextViewCoordinator.swift | 4 +- .../Utils/CursorPosition.swift | 4 +- 16 files changed, 123 insertions(+), 115 deletions(-) rename Sources/CodeEditSourceEditor/{CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift => SourceEditor/SourceEditor+Coordinator.swift} (97%) rename Sources/CodeEditSourceEditor/{CodeEditSourceEditor/CodeEditSourceEditor.swift => SourceEditor/SourceEditor.swift} (82%) rename Sources/CodeEditSourceEditor/{EditorConfig/EditorConfig+Appearance.swift => SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift} (87%) rename Sources/CodeEditSourceEditor/{EditorConfig/EditorConfig+Behavior.swift => SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift} (78%) rename Sources/CodeEditSourceEditor/{EditorConfig/EditorConfig+Layout.swift => SourceEditorConfiguration/SourceEditorConfiguration+Layout.swift} (75%) rename Sources/CodeEditSourceEditor/{EditorConfig/EditorConfig+Peripherals.swift => SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift} (85%) rename Sources/CodeEditSourceEditor/{EditorConfig/EditorConfig.swift => SourceEditorConfiguration/SourceEditorConfiguration.swift} (78%) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 4c896888b..3ad4b6a47 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -19,18 +19,23 @@ struct ContentView: View { @State private var language: CodeLanguage = .default @State private var theme: EditorTheme = .light + @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] + @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true - @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] @AppStorage("systemCursor") private var useSystemCursor: Bool = false - @State private var isInLongParse = false - @State private var settingsIsPresented: Bool = false - @State private var treeSitterClient = TreeSitterClient() - @AppStorage("showMinimap") private var showMinimap: Bool = true @State private var indentOption: IndentOption = .spaces(count: 4) @AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80 + + @AppStorage("showGutter") private var showGutter: Bool = true + @AppStorage("showMinimap") private var showMinimap: Bool = true @AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false + @State private var isInLongParse = false + @State private var settingsIsPresented: Bool = false + + @State private var treeSitterClient = TreeSitterClient() + init(document: Binding, fileURL: URL?) { self._document = document self.fileURL = fileURL @@ -38,26 +43,49 @@ struct ContentView: View { var body: some View { GeometryReader { proxy in - CodeEditSourceEditor( + SourceEditor( document.text, language: language, - theme: theme, - font: font, - tabWidth: 4, - indentOption: indentOption, - lineHeight: 1.2, - wrapLines: wrapLines, - editorOverscroll: 0.3, - cursorPositions: $cursorPositions, - useThemeBackground: true, - highlightProviders: [treeSitterClient], - contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), - additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), - useSystemCursor: useSystemCursor, - showMinimap: showMinimap, - reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + config: SourceEditorConfiguration( + appearance: .init(theme: theme, font: font, wrapLines: wrapLines), + behavior: .init(indentOption: indentOption), + layout: .init( + contentInsets: NSEdgeInsets( + top: proxy.safeAreaInsets.top, + left: showGutter ? 0 : 1, + bottom: 28.0, + right: 0 + ) + ), + peripherals: .init( + showGutter: showGutter, + showMinimap: showMinimap, + showReformattingGuide: showReformattingGuide + ) + ), + cursorPositions: $cursorPositions ) +// SourceEditor( +// document.text, +// language: language, +// font: font, +// theme: theme, +// font: font, +// tabWidth: 4, +// indentOption: indentOption, +// lineHeight: 1.2, +// wrapLines: wrapLines, +// editorOverscroll: 0.3, +// cursorPositions: $cursorPositions, +// useThemeBackground: true, +// highlightProviders: [treeSitterClient], +// contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), +// additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), +// useSystemCursor: useSystemCursor, +// showMinimap: showMinimap, +// reformatAtColumn: reformatAtColumn, +// showReformattingGuide: showReformattingGuide +// ) .overlay(alignment: .bottom) { StatusBar( fileURL: fileURL, @@ -68,6 +96,7 @@ struct ContentView: View { isInLongParse: $isInLongParse, language: $language, theme: $theme, + showGutter: $showGutter, showMinimap: $showMinimap, indentOption: $indentOption, reformatAtColumn: $reformatAtColumn, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index d471706f9..7b5bd1ef0 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -22,6 +22,7 @@ struct StatusBar: View { @Binding var isInLongParse: Bool @Binding var language: CodeLanguage @Binding var theme: EditorTheme + @Binding var showGutter: Bool @Binding var showMinimap: Bool @Binding var indentOption: IndentOption @Binding var reformatAtColumn: Int @@ -33,6 +34,7 @@ struct StatusBar: View { IndentPicker(indentOption: $indentOption, enabled: document.text.length == 0) .buttonStyle(.borderless) Toggle("Wrap Lines", isOn: $wrapLines) + Toggle("Show Gutter", isOn: $showGutter) Toggle("Show Minimap", isOn: $showMinimap) Toggle("Show Reformatting Guide", isOn: $showReformattingGuide) Picker("Reformat column at column", selection: $reformatAtColumn) { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 609e6da78..444866738 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -62,6 +62,8 @@ extension TextViewController { } setUpKeyBindings(eventMonitor: &self.localEvenMonitor) updateContentInsets() + + config.didSetOnController(controller: self, oldConfig: nil) } func setUpConstraints() { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index f58ca070e..a0332455d 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -62,7 +62,7 @@ public class TextViewController: NSViewController { /// The configuration for the editor, when updated will automatically update the controller to reflect the new /// configuration. - public var config: EditorConfig { + public var config: SourceEditorConfiguration { didSet { config.didSetOnController(controller: self, oldConfig: oldValue) } @@ -177,7 +177,7 @@ public class TextViewController: NSViewController { init( string: String, language: CodeLanguage, - config: EditorConfig, + config: SourceEditorConfiguration, cursorPositions: [CursorPosition], highlightProviders: [HighlightProviding] = [TreeSitterClient()], undoManager: CEUndoManager? = nil, diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md index 39f1102e6..4f7b6210c 100644 --- a/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md @@ -1,4 +1,4 @@ -# ``CodeEditSourceEditor`` +# ``SourceEditor`` A code editor with syntax highlighting powered by tree-sitter. diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 22e250b14..61d8c1598 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -19,7 +19,7 @@ public protocol GutterViewDelegate: AnyObject { /// /// If the gutter needs more space (when the number of digits in the numbers increases eg. adding a line after line 99), /// it will notify it's delegate via the ``GutterViewDelegate/gutterViewWidthDidUpdate(newWidth:)`` method. In -/// `CodeEditSourceEditor`, this notifies the ``TextViewController``, which in turn updates the textview's edge insets +/// `SourceEditor`, this notifies the ``TextViewController``, which in turn updates the textview's edge insets /// to adjust for the new leading inset. /// /// This view also listens for selection updates, and draws a selected background on selected lines to keep the illusion @@ -95,7 +95,11 @@ public class GutterView: NSView { true } - public convenience init(config: EditorConfig, textView: TextView, delegate: GutterViewDelegate? = nil) { + public convenience init( + config: SourceEditorConfiguration, + textView: TextView, + delegate: GutterViewDelegate? = nil + ) { self.init( font: config.appearance.font, textColor: config.appearance.theme.text.color, diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index f43d84613..64e1873b2 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -16,7 +16,7 @@ class ReformattingGuideView: NSView { didSet { needsDisplay = true } } - convenience init(config: borrowing EditorConfig) { + convenience init(config: borrowing SourceEditorConfiguration) { self.init( column: config.behavior.reformatAtColumn, theme: config.appearance.theme diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift similarity index 97% rename from Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift rename to Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index 17a14b691..3e74b5137 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor+Coordinator.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -1,5 +1,5 @@ // -// CodeEditSourceEditor+Coordinator.swift +// SourceEditor+Coordinator.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/20/24. @@ -9,7 +9,7 @@ import Foundation import SwiftUI import CodeEditTextView -extension CodeEditSourceEditor { +extension SourceEditor { @MainActor public class Coordinator: NSObject { weak var controller: TextViewController? diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift similarity index 82% rename from Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift rename to Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 3b9a4a3f7..f71ad993a 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -1,5 +1,5 @@ // -// CodeEditSourceEditor.swift +// SourceEditor.swift // CodeEditSourceEditor // // Created by Lukas Pistrol on 24.05.22. @@ -11,7 +11,7 @@ import CodeEditTextView import CodeEditLanguages /// A SwiftUI View that provides source editing functionality. -public struct CodeEditSourceEditor: NSViewControllerRepresentable { +public struct SourceEditor: NSViewControllerRepresentable { package enum TextAPI { case binding(Binding) case storage(NSTextStorage) @@ -21,7 +21,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - Parameters: /// - text: The text content /// - language: The language for syntax highlighting - /// - config: A configuration object, determining appearance, layout, behaviors and more. See ``EditorConfig``. + /// - config: A configuration object, determining appearance, layout, behaviors and more. + /// See ``SourceEditorConfiguration``. /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` /// - highlightProviders: A set of classes you provide to perform syntax highlighting. Leave this as `nil` to use /// the default `TreeSitterClient` highlighter. @@ -30,7 +31,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { public init( _ text: Binding, language: CodeLanguage, - config: EditorConfig, + config: SourceEditorConfiguration, cursorPositions: Binding<[CursorPosition]>, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, @@ -49,7 +50,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// - Parameters: /// - text: The text content /// - language: The language for syntax highlighting - /// - config: A configuration object, determining appearance, layout, behaviors and more. See ``EditorConfig``. + /// - config: A configuration object, determining appearance, layout, behaviors and more. + /// See ``SourceEditorConfiguration``. /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` /// - highlightProviders: A set of classes you provide to perform syntax highlighting. Leave this as `nil` to use /// the default `TreeSitterClient` highlighter. @@ -58,7 +60,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { public init( _ text: NSTextStorage, language: CodeLanguage, - config: EditorConfig, + config: SourceEditorConfiguration, cursorPositions: Binding<[CursorPosition]>, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, @@ -75,7 +77,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { package var text: TextAPI private var language: CodeLanguage - private var config: EditorConfig + private var config: SourceEditorConfiguration package var cursorPositions: Binding<[CursorPosition]> private var highlightProviders: [any HighlightProviding]? private var undoManager: CEUndoManager? @@ -135,6 +137,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { return } + if controller.language != language { + controller.language = language + } controller.config = config updateHighlighting(controller, coordinator: context.coordinator) @@ -162,38 +167,3 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { == coordinator.highlightProviders.map { ObjectIdentifier($0) } } } - -// swiftlint:disable:next line_length -@available(*, unavailable, renamed: "CodeEditSourceEditor", message: "CodeEditTextView has been renamed to CodeEditSourceEditor.") -public struct CodeEditTextView: View { - public init( - _ text: Binding, - language: CodeLanguage, - theme: EditorTheme, - font: NSFont, - tabWidth: Int, - indentOption: IndentOption = .spaces(count: 4), - lineHeight: Double, - wrapLines: Bool, - editorOverscroll: CGFloat = 0, - cursorPositions: Binding<[CursorPosition]>, - useThemeBackground: Bool = true, - highlightProvider: HighlightProviding? = nil, - contentInsets: NSEdgeInsets? = nil, - isEditable: Bool = true, - isSelectable: Bool = true, - letterSpacing: Double = 1.0, - bracketPairEmphasis: BracketPairEmphasis? = nil, - undoManager: CEUndoManager? = nil, - coordinators: [any TextViewCoordinator] = [] - ) { - - } - - public var body: some View { - EmptyView() - } -} - -// swiftlint:enable type_body_length -// swiftlint:enable file_length diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift similarity index 87% rename from Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift rename to Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index bcf0148f4..da6c8e513 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Appearance.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -1,5 +1,5 @@ // -// EditorConfig+Appearance.swift +// SourceEditorConfiguration+Appearance.swift // CodeEditSourceEditor // // Created by Khan Winter on 6/16/25. @@ -7,7 +7,7 @@ import AppKit -extension EditorConfig { +extension SourceEditorConfiguration { public struct Appearance: Equatable { /// The theme for syntax highlighting. public var theme: EditorTheme @@ -42,11 +42,11 @@ extension EditorConfig { theme: EditorTheme, useThemeBackground: Bool = true, font: NSFont, - lineHeightMultiple: Double, + lineHeightMultiple: Double = 1.2, letterSpacing: Double = 1.0, wrapLines: Bool, useSystemCursor: Bool = true, - tabWidth: Int, + tabWidth: Int = 4, bracketPairEmphasis: BracketPairEmphasis? = .flash ) { self.theme = theme @@ -65,15 +65,15 @@ extension EditorConfig { } @MainActor - func didSetOnController(controller: TextViewController, oldConfig: Appearance) { + func didSetOnController(controller: TextViewController, oldConfig: Appearance?) { var needsHighlighterInvalidation = false - if oldConfig.font != font { + if oldConfig?.font != font { controller.textView.font = font needsHighlighterInvalidation = true } - if oldConfig.theme != theme { + if oldConfig?.theme != theme { controller.textView.layoutManager.setNeedsLayout() controller.textView.textStorage.setAttributes( controller.attributesFor(nil), @@ -87,17 +87,17 @@ extension EditorConfig { needsHighlighterInvalidation = true } - if oldConfig.tabWidth != tabWidth { + if oldConfig?.tabWidth != tabWidth { controller.paragraphStyle = controller.generateParagraphStyle() controller.textView.layoutManager.setNeedsLayout() needsHighlighterInvalidation = true } - if oldConfig.lineHeightMultiple != lineHeightMultiple { + if oldConfig?.lineHeightMultiple != lineHeightMultiple { controller.textView.layoutManager.lineHeightMultiplier = lineHeightMultiple } - if oldConfig.wrapLines != wrapLines { + if oldConfig?.wrapLines != wrapLines { controller.textView.layoutManager.wrapLines = wrapLines controller.minimapView.layoutManager?.wrapLines = wrapLines controller.scrollView.hasHorizontalScroller = !wrapLines @@ -106,18 +106,18 @@ extension EditorConfig { // useThemeBackground isn't needed - if oldConfig.letterSpacing != letterSpacing { + if oldConfig?.letterSpacing != letterSpacing { controller.textView.letterSpacing = letterSpacing needsHighlighterInvalidation = true } - if oldConfig.bracketPairEmphasis != bracketPairEmphasis { + if oldConfig?.bracketPairEmphasis != bracketPairEmphasis { controller.emphasizeSelectionPairs() } // Cant put these in one if sadly if #available(macOS 14, *) { - if oldConfig.useSystemCursor != useSystemCursor { + if oldConfig?.useSystemCursor != useSystemCursor { controller.textView.useSystemCursor = useSystemCursor } } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift similarity index 78% rename from Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift rename to Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift index befb8e516..7dbb36656 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Behavior.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift @@ -1,11 +1,11 @@ // -// EditorConfig+Behavior.swift +// SourceEditorConfiguration+Behavior.swift // CodeEditSourceEditor // // Created by Khan Winter on 6/16/25. // -extension EditorConfig { +extension SourceEditorConfiguration { public struct Behavior: Equatable { /// Controls whether the text view allows the user to edit text. public var isEditable: Bool = true @@ -33,21 +33,22 @@ extension EditorConfig { } @MainActor - func didSetOnController(controller: TextViewController, oldConfig: Behavior) { - if oldConfig.isEditable != isEditable { + func didSetOnController(controller: TextViewController, oldConfig: Behavior?) { + if oldConfig?.isEditable != isEditable { controller.textView.isEditable = isEditable } - if oldConfig.isSelectable != isSelectable { + if oldConfig?.isSelectable != isSelectable { controller.textView.isSelectable = isSelectable } - if oldConfig.indentOption != indentOption { + if oldConfig?.indentOption != indentOption { controller.setUpTextFormation() } - if oldConfig.reformatAtColumn != reformatAtColumn { + if oldConfig?.reformatAtColumn != reformatAtColumn { controller.reformattingGuideView.column = reformatAtColumn + controller.reformattingGuideView.updatePosition(in: controller.textView) } } } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Layout.swift similarity index 75% rename from Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift rename to Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Layout.swift index 2bead02cd..aae7e465e 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Layout.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Layout.swift @@ -1,5 +1,5 @@ // -// EditorConfig+Layout.swift +// SourceEditorConfiguration+Layout.swift // CodeEditSourceEditor // // Created by Khan Winter on 6/16/25. @@ -7,7 +7,7 @@ import AppKit -extension EditorConfig { +extension SourceEditorConfiguration { public struct Layout: Equatable { /// The distance to overscroll the editor by, as a multiple of the visible editor height. public var editorOverscroll: CGFloat = 0 @@ -22,7 +22,7 @@ extension EditorConfig { public init( editorOverscroll: CGFloat = 0, contentInsets: NSEdgeInsets? = nil, - additionalTextInsets: NSEdgeInsets? = nil + additionalTextInsets: NSEdgeInsets? = NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0) ) { self.editorOverscroll = editorOverscroll self.contentInsets = contentInsets @@ -30,16 +30,16 @@ extension EditorConfig { } @MainActor - func didSetOnController(controller: TextViewController, oldConfig: Layout) { - if oldConfig.editorOverscroll != editorOverscroll { + func didSetOnController(controller: TextViewController, oldConfig: Layout?) { + if oldConfig?.editorOverscroll != editorOverscroll { controller.textView.overscrollAmount = editorOverscroll } - if oldConfig.contentInsets != contentInsets { + if oldConfig?.contentInsets != contentInsets { controller.updateContentInsets() } - if oldConfig.additionalTextInsets != additionalTextInsets { + if oldConfig?.additionalTextInsets != additionalTextInsets { controller.styleScrollView() } } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift similarity index 85% rename from Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift rename to Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index 61051be5b..2679e8211 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig+Peripherals.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -5,7 +5,7 @@ // Created by Khan Winter on 6/16/25. // -extension EditorConfig { +extension SourceEditorConfiguration { public struct Peripherals: Equatable { /// Whether to show the gutter. public var showGutter: Bool = true @@ -27,20 +27,20 @@ extension EditorConfig { } @MainActor - func didSetOnController(controller: TextViewController, oldConfig: Peripherals) { + func didSetOnController(controller: TextViewController, oldConfig: Peripherals?) { var shouldUpdateInsets = false - if oldConfig.showGutter != showGutter { + if oldConfig?.showGutter != showGutter { controller.gutterView.isHidden = !showGutter shouldUpdateInsets = true } - if oldConfig.showMinimap != showMinimap { + if oldConfig?.showMinimap != showMinimap { controller.minimapView?.isHidden = !showMinimap shouldUpdateInsets = true } - if oldConfig.showReformattingGuide != showReformattingGuide { + if oldConfig?.showReformattingGuide != showReformattingGuide { controller.reformattingGuideView.isHidden = !showReformattingGuide controller.reformattingGuideView.updatePosition(in: controller.textView) } diff --git a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift similarity index 78% rename from Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift rename to Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift index d677e42bc..f198198ab 100644 --- a/Sources/CodeEditSourceEditor/EditorConfig/EditorConfig.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift @@ -1,5 +1,5 @@ // -// EditorConfig.swift +// SourceEditorConfiguration.swift // CodeEditSourceEditor // // Created by Khan Winter on 6/16/25. @@ -7,7 +7,7 @@ import AppKit -public struct EditorConfig: Equatable { +public struct SourceEditorConfiguration: Equatable { public var appearance: Appearance public var behavior: Behavior public var peripherals: Peripherals @@ -26,10 +26,10 @@ public struct EditorConfig: Equatable { } @MainActor - func didSetOnController(controller: TextViewController, oldConfig: EditorConfig) { - appearance.didSetOnController(controller: controller, oldConfig: oldConfig.appearance) - behavior.didSetOnController(controller: controller, oldConfig: oldConfig.behavior) - layout.didSetOnController(controller: controller, oldConfig: oldConfig.layout) - peripherals.didSetOnController(controller: controller, oldConfig: oldConfig.peripherals) + func didSetOnController(controller: TextViewController, oldConfig: SourceEditorConfiguration?) { + appearance.didSetOnController(controller: controller, oldConfig: oldConfig?.appearance) + behavior.didSetOnController(controller: controller, oldConfig: oldConfig?.behavior) + layout.didSetOnController(controller: controller, oldConfig: oldConfig?.layout) + peripherals.didSetOnController(controller: controller, oldConfig: oldConfig?.peripherals) } } diff --git a/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift b/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift index c19cf5d11..82158c2b1 100644 --- a/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift +++ b/Sources/CodeEditSourceEditor/TextViewCoordinator/TextViewCoordinator.swift @@ -7,10 +7,10 @@ import AppKit -/// A protocol that can be used to receive extra state change messages from ``CodeEditSourceEditor``. +/// A protocol that can be used to receive extra state change messages from ``SourceEditor``. /// /// These are used as a way to push messages up from underlying components into SwiftUI land without requiring passing -/// callbacks for each message to the ``CodeEditSourceEditor`` initializer. +/// callbacks for each message to the ``SourceEditor`` initializer. /// /// They're very useful for updating UI that is directly related to the state of the editor, such as the current /// cursor position. For an example, see the ``CombineCoordinator`` class, which implements combine publishers for the diff --git a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift index 287aeb10a..508684527 100644 --- a/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift +++ b/Sources/CodeEditSourceEditor/Utils/CursorPosition.swift @@ -20,7 +20,7 @@ public struct CursorPosition: Sendable, Codable, Equatable, Hashable { /// Initialize a cursor position. /// /// When this initializer is used, ``CursorPosition/range`` will be initialized to `NSNotFound`. - /// The range value, however, be filled when updated by ``CodeEditSourceEditor`` via a `Binding`, or when it appears + /// The range value, however, be filled when updated by ``SourceEditor`` via a `Binding`, or when it appears /// in the``TextViewController/cursorPositions`` array. /// /// - Parameters: @@ -35,7 +35,7 @@ public struct CursorPosition: Sendable, Codable, Equatable, Hashable { /// Initialize a cursor position. /// /// When this initializer is used, both ``CursorPosition/line`` and ``CursorPosition/column`` will be initialized - /// to `-1`. They will, however, be filled when updated by ``CodeEditSourceEditor`` via a `Binding`, or when it + /// to `-1`. They will, however, be filled when updated by ``SourceEditor`` via a `Binding`, or when it /// appears in the ``TextViewController/cursorPositions`` array. /// /// - Parameter range: The range of the cursor position. From 34387a83b224769f67d636617e13cc612d282796 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 14:16:04 -0500 Subject: [PATCH 04/23] Fix Reformatting Guide X Position --- .../Views/ContentView.swift | 5 ++++- .../Controller/TextViewController+LoadView.swift | 14 ++++++-------- .../Controller/TextViewController+ReloadUI.swift | 2 +- .../Controller/TextViewController+StyleViews.swift | 2 +- .../ReformattingGuide/ReformattingGuideView.swift | 12 +++++++----- .../SourceEditorConfiguration+Behavior.swift | 3 ++- .../SourceEditorConfiguration+Peripherals.swift | 2 +- 7 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 3ad4b6a47..1add237e9 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -48,7 +48,10 @@ struct ContentView: View { language: language, config: SourceEditorConfiguration( appearance: .init(theme: theme, font: font, wrapLines: wrapLines), - behavior: .init(indentOption: indentOption), + behavior: .init( + indentOption: indentOption, + reformatAtColumn: reformatAtColumn + ), layout: .init( contentInsets: NSEdgeInsets( top: proxy.safeAreaInsets.top, diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 444866738..341185ef6 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -124,14 +124,12 @@ extension TextViewController { object: textView, queue: .main ) { [weak self] _ in - guard let textView = self?.textView else { return } - self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 - self?.gutterView.frame.origin.y = (self?.textView.frame.origin.y ?? 0.0) - - (self?.scrollView.contentInsets.top ?? 0) - - self?.gutterView.needsDisplay = true - self?.reformattingGuideView?.updatePosition(in: textView) - self?.scrollView.needsLayout = true + guard let self else { return } + self.gutterView.frame.size.height = self.textView.frame.height + 10 + self.gutterView.frame.origin.y = self.textView.frame.origin.y - self.scrollView.contentInsets.top + self.gutterView.needsDisplay = true + self.reformattingGuideView?.updatePosition(in: self) + self.scrollView.needsLayout = true } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift index aa7b4d5fd..9c4aeb9cf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift @@ -22,7 +22,7 @@ extension TextViewController { // Update reformatting guide position if let guideView = textView.subviews.first(where: { $0 is ReformattingGuideView }) as? ReformattingGuideView { - guideView.updatePosition(in: textView) + guideView.updatePosition(in: self) } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index a05eab7d4..26d383b0e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -87,7 +87,7 @@ extension TextViewController { } package func styleReformattingGuideView() { - reformattingGuideView.updatePosition(in: textView) + reformattingGuideView.updatePosition(in: self) reformattingGuideView.isHidden = !showReformattingGuide } diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index 64e1873b2..3d70aea84 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -74,13 +74,15 @@ class ReformattingGuideView: NSView { shadedRect.fill() } - func updatePosition(in textView: TextView) { + func updatePosition(in controller: TextViewController) { // Calculate the x position based on the font's character width and column number - let charWidth = textView.font.boundingRectForFont.width - let xPosition = CGFloat(column) * charWidth / 2 // Divide by 2 to account for coordinate system + let xPosition = ( + CGFloat(column) * (controller.fontCharWidth / 2) // Divide by 2 to account for coordinate system + + (controller.textViewInsets.left / 2) + ) // Get the scroll view's content size - guard let scrollView = textView.enclosingScrollView else { return } + guard let scrollView = controller.scrollView else { return } let contentSize = scrollView.documentVisibleRect.size // Ensure we don't create an invalid frame @@ -90,7 +92,7 @@ class ReformattingGuideView: NSView { let newFrame = NSRect( x: xPosition, y: 0, // Start above the visible area - width: maxWidth + 1000, + width: maxWidth, height: contentSize.height // Use extended height ).pixelAligned diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift index 7dbb36656..4eab9b936 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift @@ -48,7 +48,8 @@ extension SourceEditorConfiguration { if oldConfig?.reformatAtColumn != reformatAtColumn { controller.reformattingGuideView.column = reformatAtColumn - controller.reformattingGuideView.updatePosition(in: controller.textView) + controller.reformattingGuideView.updatePosition(in: controller) + controller.view.updateConstraintsForSubtreeIfNeeded() } } } diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index 2679e8211..51801cfc4 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -42,7 +42,7 @@ extension SourceEditorConfiguration { if oldConfig?.showReformattingGuide != showReformattingGuide { controller.reformattingGuideView.isHidden = !showReformattingGuide - controller.reformattingGuideView.updatePosition(in: controller.textView) + controller.reformattingGuideView.updatePosition(in: controller) } if shouldUpdateInsets && controller.scrollView != nil { // Check for view existence From ebd4e254a0e2e3cd492aee446421c306afca4a3a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 14:26:56 -0500 Subject: [PATCH 05/23] Finish rename, fix tests --- .../Views/ContentView.swift | 36 ++---------- .../TextViewController+EmphasizeBracket.swift | 4 +- .../TextViewController+Highlighter.swift | 4 +- .../TextViewController+IndentLines.swift | 6 +- .../TextViewController+LoadView.swift | 8 +-- .../TextViewController+ReloadUI.swift | 4 +- .../TextViewController+StyleViews.swift | 12 ++-- .../TextViewController+TextFormation.swift | 10 ++-- .../Controller/TextViewController.swift | 44 +++++++------- .../Gutter/GutterView.swift | 8 +-- .../ReformattingGuideView.swift | 6 +- .../SourceEditor/SourceEditor.swift | 24 ++++---- .../TextViewController+IndentTests.swift | 8 +-- .../Controller/TextViewControllerTests.swift | 57 ++++++++++--------- Tests/CodeEditSourceEditorTests/Mock.swift | 4 +- .../TagEditingTests.swift | 8 +-- 16 files changed, 110 insertions(+), 133 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 1add237e9..829565423 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -36,6 +36,10 @@ struct ContentView: View { @State private var treeSitterClient = TreeSitterClient() + private func contentInsets(proxy: GeometryProxy) -> NSEdgeInsets { + NSEdgeInsets(top: proxy.safeAreaInsets.top, left: showGutter ? 0 : 1, bottom: 28.0, right: 0) + } + init(document: Binding, fileURL: URL?) { self._document = document self.fileURL = fileURL @@ -46,20 +50,13 @@ struct ContentView: View { SourceEditor( document.text, language: language, - config: SourceEditorConfiguration( + configuration: SourceEditorConfiguration( appearance: .init(theme: theme, font: font, wrapLines: wrapLines), behavior: .init( indentOption: indentOption, reformatAtColumn: reformatAtColumn ), - layout: .init( - contentInsets: NSEdgeInsets( - top: proxy.safeAreaInsets.top, - left: showGutter ? 0 : 1, - bottom: 28.0, - right: 0 - ) - ), + layout: .init(contentInsets: contentInsets(proxy: proxy)), peripherals: .init( showGutter: showGutter, showMinimap: showMinimap, @@ -68,27 +65,6 @@ struct ContentView: View { ), cursorPositions: $cursorPositions ) -// SourceEditor( -// document.text, -// language: language, -// font: font, -// theme: theme, -// font: font, -// tabWidth: 4, -// indentOption: indentOption, -// lineHeight: 1.2, -// wrapLines: wrapLines, -// editorOverscroll: 0.3, -// cursorPositions: $cursorPositions, -// useThemeBackground: true, -// highlightProviders: [treeSitterClient], -// contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), -// additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), -// useSystemCursor: useSystemCursor, -// showMinimap: showMinimap, -// reformatAtColumn: reformatAtColumn, -// showReformattingGuide: showReformattingGuide -// ) .overlay(alignment: .bottom) { StatusBar( fileURL: fileURL, diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift index 3a2970382..c4c8633b1 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift @@ -11,7 +11,7 @@ import CodeEditTextView extension TextViewController { /// Emphasizes bracket pairs using the current selection. internal func emphasizeSelectionPairs() { - guard let bracketPairEmphasis = config.appearance.bracketPairEmphasis else { return } + guard let bracketPairEmphasis = configuration.appearance.bracketPairEmphasis else { return } textView.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) for range in textView.selectionManager.textSelections.map({ $0.range }) { if range.isEmpty, @@ -119,7 +119,7 @@ extension TextViewController { /// - location: The location of the character to emphasize /// - scrollToRange: Set to true to scroll to the given range when emphasizing. Defaults to `false`. private func emphasizeCharacter(_ location: Int, scrollToRange: Bool = false) { - guard let bracketPairEmphasis = config.appearance.bracketPairEmphasis else { + guard let bracketPairEmphasis = configuration.appearance.bracketPairEmphasis else { return } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift index 50f221c4e..ce5cc0876 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -41,8 +41,8 @@ extension TextViewController { extension TextViewController: ThemeAttributesProviding { public func attributesFor(_ capture: CaptureName?) -> [NSAttributedString.Key: Any] { [ - .font: config.appearance.theme.fontFor(for: capture, from: font), - .foregroundColor: config.appearance.theme.colorFor(capture), + .font: configuration.appearance.theme.fontFor(for: capture, from: font), + .foregroundColor: configuration.appearance.theme.colorFor(capture), .kern: textView.kern ] } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift index 019171f0c..ce0188f62 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+IndentLines.swift @@ -97,7 +97,7 @@ extension TextViewController { lineCount: lineCount ) - let charCount = config.behavior.indentOption.charCount + let charCount = configuration.behavior.indentOption.charCount selection.range.location += inwards ? -charCount * sectionModifier : charCount * sectionModifier if lineCount > 1 { @@ -169,7 +169,7 @@ extension TextViewController { } private func adjustIndentation(lineIndexes: ClosedRange, inwards: Bool) { - let indentationChars: String = config.behavior.indentOption.stringValue + let indentationChars: String = configuration.behavior.indentOption.stringValue for lineIndex in lineIndexes { adjustIndentation( lineIndex: lineIndex, @@ -183,7 +183,7 @@ extension TextViewController { guard let lineInfo = textView.layoutManager.textLineForIndex(lineIndex) else { return } if inwards { - if config.behavior.indentOption != .tab { + if configuration.behavior.indentOption != .tab { removeLeadingSpaces(lineInfo: lineInfo, spaceCount: indentationChars.count) } else { removeLeadingTab(lineInfo: lineInfo) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 341185ef6..410ec8337 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -16,17 +16,17 @@ extension TextViewController { scrollView.documentView = textView gutterView = GutterView( - config: config, + configuration: configuration, textView: textView, delegate: self ) gutterView.updateWidthIfNeeded() scrollView.addFloatingSubview(gutterView, for: .horizontal) - reformattingGuideView = ReformattingGuideView(config: config) + reformattingGuideView = ReformattingGuideView(configuration: configuration) scrollView.addFloatingSubview(reformattingGuideView, for: .vertical) - minimapView = MinimapView(textView: textView, theme: config.appearance.theme) + minimapView = MinimapView(textView: textView, theme: configuration.appearance.theme) scrollView.addFloatingSubview(minimapView, for: .vertical) let findViewController = FindViewController(target: self, childView: scrollView) @@ -63,7 +63,7 @@ extension TextViewController { setUpKeyBindings(eventMonitor: &self.localEvenMonitor) updateContentInsets() - config.didSetOnController(controller: self, oldConfig: nil) + configuration.didSetOnController(controller: self, oldConfig: nil) } func setUpConstraints() { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift index 9c4aeb9cf..659c95b71 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift @@ -9,8 +9,8 @@ import AppKit extension TextViewController { func reloadUI() { - textView.isEditable = config.behavior.isEditable - textView.isSelectable = config.behavior.isSelectable + textView.isEditable = configuration.behavior.isEditable + textView.isSelectable = configuration.behavior.isSelectable styleScrollView() styleTextView() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 26d383b0e..b0d9e83bc 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -23,7 +23,7 @@ extension TextViewController { textView.translatesAutoresizingMaskIntoConstraints = false textView.selectionManager.selectionBackgroundColor = theme.selection textView.selectionManager.selectedLineBackgroundColor = getThemeBackground() - textView.selectionManager.highlightSelectedLine = config.behavior.isEditable + textView.selectionManager.highlightSelectedLine = configuration.behavior.isEditable textView.selectionManager.insertionPointColor = theme.insertionPoint textView.enclosingScrollView?.backgroundColor = if useThemeBackground { theme.background @@ -58,14 +58,14 @@ extension TextViewController { } else { NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) } - gutterView.highlightSelectedLines = config.behavior.isEditable + gutterView.highlightSelectedLines = configuration.behavior.isEditable gutterView.font = font.rulerFont gutterView.backgroundColor = if useThemeBackground { theme.background } else { .windowBackgroundColor } - if config.behavior.isEditable == false { + if configuration.behavior.isEditable == false { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear } @@ -96,7 +96,7 @@ extension TextViewController { updateTextInsets() scrollView.contentView.postsBoundsChangedNotifications = true - if let contentInsets = config.layout.contentInsets { + if let contentInsets = configuration.layout.contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets @@ -109,7 +109,7 @@ extension TextViewController { } // `additionalTextInsets` only effects text content. - let additionalTextInsets = config.layout.additionalTextInsets + let additionalTextInsets = configuration.layout.additionalTextInsets scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 minimapView.scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 @@ -124,7 +124,7 @@ extension TextViewController { scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset - findViewController?.topPadding = config.layout.contentInsets?.top + findViewController?.topPadding = configuration.layout.contentInsets?.top gutterView.frame.origin.y = textView.frame.origin.y - scrollView.contentInsets.top diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 24b3296c3..e7a67f13c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -38,9 +38,9 @@ extension TextViewController { setUpOpenPairFilters(pairs: BracketPairs.allValues) setUpTagFilter() - setUpNewlineTabFilters(indentOption: config.behavior.indentOption) + setUpNewlineTabFilters(indentOption: configuration.behavior.indentOption) setUpDeletePairFilters(pairs: BracketPairs.allValues) - setUpDeleteWhitespaceFilter(indentOption: config.behavior.indentOption) + setUpDeleteWhitespaceFilter(indentOption: configuration.behavior.indentOption) } /// Returns a `TextualIndenter` based on available language configuration. @@ -95,7 +95,7 @@ extension TextViewController { guard let treeSitterClient, language.id.shouldProcessTags() else { return } textFilters.append(TagFilter( language: self.language, - indentOption: config.behavior.indentOption, + indentOption: configuration.behavior.indentOption, lineEnding: textView.layoutManager.detectedLineEnding, treeSitterClient: treeSitterClient )) @@ -112,12 +112,12 @@ extension TextViewController { return true } - let indentationUnit = config.behavior.indentOption.stringValue + let indentationUnit = configuration.behavior.indentOption.stringValue let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( leadingWhitespace: indenter.substitionProvider( indentationUnit: indentationUnit, - width: config.appearance.tabWidth + width: configuration.appearance.tabWidth ), trailingWhitespace: { _, _ in "" } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index a0332455d..602a8120e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -62,9 +62,9 @@ public class TextViewController: NSViewController { /// The configuration for the editor, when updated will automatically update the controller to reflect the new /// configuration. - public var config: SourceEditorConfiguration { + public var configuration: SourceEditorConfiguration { didSet { - config.didSetOnController(controller: self, oldConfig: oldValue) + configuration.didSetOnController(controller: self, oldConfig: oldValue) } } @@ -77,68 +77,68 @@ public class TextViewController: NSViewController { // MARK: - Config Helpers /// The font to use in the `textView` - public var font: NSFont { config.appearance.font } + public var font: NSFont { configuration.appearance.font } /// The ``EditorTheme`` used for highlighting. - public var theme: EditorTheme { config.appearance.theme } + public var theme: EditorTheme { configuration.appearance.theme } /// The visual width of tab characters in the text view measured in number of spaces. - public var tabWidth: Int { config.appearance.tabWidth } + public var tabWidth: Int { configuration.appearance.tabWidth } /// The behavior to use when the tab key is pressed. - public var indentOption: IndentOption { config.behavior.indentOption } + public var indentOption: IndentOption { configuration.behavior.indentOption } /// A multiplier for setting the line height. Defaults to `1.0` - public var lineHeightMultiple: CGFloat { config.appearance.lineHeightMultiple } + public var lineHeightMultiple: CGFloat { configuration.appearance.lineHeightMultiple } /// Whether lines wrap to the width of the editor - public var wrapLines: Bool { config.appearance.wrapLines } + public var wrapLines: Bool { configuration.appearance.wrapLines } /// The editorOverscroll to use for the textView over scroll /// /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll /// of 1/3 of the view. - public var editorOverscroll: CGFloat { config.layout.editorOverscroll } + public var editorOverscroll: CGFloat { configuration.layout.editorOverscroll } /// Whether the code editor should use the theme background color or be transparent - public var useThemeBackground: Bool { config.appearance.useThemeBackground } + public var useThemeBackground: Bool { configuration.appearance.useThemeBackground } /// Optional insets to offset the text view and find panel in the scroll view by. - public var contentInsets: NSEdgeInsets? { config.layout.contentInsets } + public var contentInsets: NSEdgeInsets? { configuration.layout.contentInsets } /// An additional amount to inset text by. Horizontal values are ignored. /// /// This value does not affect decorations like the find panel, but affects things that are relative to text, such /// as line numbers and of course the text itself. - public var additionalTextInsets: NSEdgeInsets? { config.layout.additionalTextInsets } + public var additionalTextInsets: NSEdgeInsets? { configuration.layout.additionalTextInsets } /// Whether or not text view is editable by user - public var isEditable: Bool { config.behavior.isEditable } + public var isEditable: Bool { configuration.behavior.isEditable } /// Whether or not text view is selectable by user - public var isSelectable: Bool { config.behavior.isSelectable } + public var isSelectable: Bool { configuration.behavior.isSelectable } /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, /// `2.0` indicates one character of space between other characters. - public var letterSpacing: Double { config.appearance.letterSpacing } + public var letterSpacing: Double { configuration.appearance.letterSpacing } /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. - public var bracketPairEmphasis: BracketPairEmphasis? { config.appearance.bracketPairEmphasis } + public var bracketPairEmphasis: BracketPairEmphasis? { configuration.appearance.bracketPairEmphasis } /// The column at which to show the reformatting guide - public var reformatAtColumn: Int { config.behavior.reformatAtColumn } + public var reformatAtColumn: Int { configuration.behavior.reformatAtColumn } /// If true, uses the system cursor on macOS 14 or greater. - public var useSystemCursor: Bool { config.appearance.useSystemCursor } + public var useSystemCursor: Bool { configuration.appearance.useSystemCursor } /// Toggle the visibility of the gutter view in the editor. - public var showGutter: Bool { config.peripherals.showGutter } + public var showGutter: Bool { configuration.peripherals.showGutter } /// Toggle the visibility of the minimap view in the editor. - public var showMinimap: Bool { config.peripherals.showMinimap } + public var showMinimap: Bool { configuration.peripherals.showMinimap } /// Toggle the visibility of the reformatting guide in the editor. - public var showReformattingGuide: Bool { config.peripherals.showReformattingGuide } + public var showReformattingGuide: Bool { configuration.peripherals.showReformattingGuide } // MARK: - Internal Variables @@ -184,7 +184,7 @@ public class TextViewController: NSViewController { coordinators: [TextViewCoordinator] = [], ) { self.language = language - self.config = config + self.configuration = config self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self._undoManager = undoManager diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 61d8c1598..297d3e9c3 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -96,14 +96,14 @@ public class GutterView: NSView { } public convenience init( - config: SourceEditorConfiguration, + configuration: borrowing SourceEditorConfiguration, textView: TextView, delegate: GutterViewDelegate? = nil ) { self.init( - font: config.appearance.font, - textColor: config.appearance.theme.text.color, - selectedTextColor: config.appearance.theme.selection, + font: configuration.appearance.font, + textColor: configuration.appearance.theme.text.color, + selectedTextColor: configuration.appearance.theme.selection, textView: textView, delegate: delegate ) diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index 3d70aea84..e4033781e 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -16,10 +16,10 @@ class ReformattingGuideView: NSView { didSet { needsDisplay = true } } - convenience init(config: borrowing SourceEditorConfiguration) { + convenience init(configuration: borrowing SourceEditorConfiguration) { self.init( - column: config.behavior.reformatAtColumn, - theme: config.appearance.theme + column: configuration.behavior.reformatAtColumn, + theme: configuration.appearance.theme ) } diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index f71ad993a..e0fd3ab10 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -21,8 +21,8 @@ public struct SourceEditor: NSViewControllerRepresentable { /// - Parameters: /// - text: The text content /// - language: The language for syntax highlighting - /// - config: A configuration object, determining appearance, layout, behaviors and more. - /// See ``SourceEditorConfiguration``. + /// - configuration: A configuration object, determining appearance, layout, behaviors and more. + /// See ``SourceEditorConfiguration``. /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` /// - highlightProviders: A set of classes you provide to perform syntax highlighting. Leave this as `nil` to use /// the default `TreeSitterClient` highlighter. @@ -31,7 +31,7 @@ public struct SourceEditor: NSViewControllerRepresentable { public init( _ text: Binding, language: CodeLanguage, - config: SourceEditorConfiguration, + configuration: SourceEditorConfiguration, cursorPositions: Binding<[CursorPosition]>, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, @@ -39,7 +39,7 @@ public struct SourceEditor: NSViewControllerRepresentable { ) { self.text = .binding(text) self.language = language - self.config = config + self.configuration = configuration self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self.undoManager = undoManager @@ -50,8 +50,8 @@ public struct SourceEditor: NSViewControllerRepresentable { /// - Parameters: /// - text: The text content /// - language: The language for syntax highlighting - /// - config: A configuration object, determining appearance, layout, behaviors and more. - /// See ``SourceEditorConfiguration``. + /// - configuration: A configuration object, determining appearance, layout, behaviors and more. + /// See ``SourceEditorConfiguration``. /// - cursorPositions: The cursor's position in the editor, measured in `(lineNum, columnNum)` /// - highlightProviders: A set of classes you provide to perform syntax highlighting. Leave this as `nil` to use /// the default `TreeSitterClient` highlighter. @@ -60,7 +60,7 @@ public struct SourceEditor: NSViewControllerRepresentable { public init( _ text: NSTextStorage, language: CodeLanguage, - config: SourceEditorConfiguration, + configuration: SourceEditorConfiguration, cursorPositions: Binding<[CursorPosition]>, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, @@ -68,7 +68,7 @@ public struct SourceEditor: NSViewControllerRepresentable { ) { self.text = .storage(text) self.language = language - self.config = config + self.configuration = configuration self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self.undoManager = undoManager @@ -77,7 +77,7 @@ public struct SourceEditor: NSViewControllerRepresentable { package var text: TextAPI private var language: CodeLanguage - private var config: SourceEditorConfiguration + private var configuration: SourceEditorConfiguration package var cursorPositions: Binding<[CursorPosition]> private var highlightProviders: [any HighlightProviding]? private var undoManager: CEUndoManager? @@ -89,7 +89,7 @@ public struct SourceEditor: NSViewControllerRepresentable { let controller = TextViewController( string: "", language: language, - config: config, + config: configuration, cursorPositions: cursorPositions.wrappedValue, highlightProviders: context.coordinator.highlightProviders, undoManager: undoManager, @@ -140,7 +140,7 @@ public struct SourceEditor: NSViewControllerRepresentable { if controller.language != language { controller.language = language } - controller.config = config + controller.configuration = configuration updateHighlighting(controller, coordinator: context.coordinator) controller.reloadUI() @@ -158,7 +158,7 @@ public struct SourceEditor: NSViewControllerRepresentable { /// - Returns: True, if the controller's parameters should be updated. func paramsAreEqual(controller: NSViewControllerType, coordinator: Coordinator) -> Bool { controller.language.id == language.id && - controller.config == config && + controller.configuration == configuration && areHighlightProvidersEqual(controller: controller, coordinator: coordinator) } diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift index 133b13e0f..92eaededb 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewController+IndentTests.swift @@ -51,7 +51,7 @@ final class TextViewControllerIndentTests: XCTestCase { func testHandleIndentWithTabsInwards() { controller.setText("\tThis is a test string") - controller.config.behavior .indentOption = .tab + controller.configuration.behavior .indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions @@ -63,7 +63,7 @@ final class TextViewControllerIndentTests: XCTestCase { func testHandleIndentWithTabsOutwards() { controller.setText("This is a test string") - controller.config.behavior.indentOption = .tab + controller.configuration.behavior.indentOption = .tab let cursorPositions = [CursorPosition(range: NSRange(location: 0, length: 0))] controller.textView.selectionManager.textSelections = [.init(range: NSRange(location: 0, length: 0))] controller.cursorPositions = cursorPositions @@ -76,7 +76,7 @@ final class TextViewControllerIndentTests: XCTestCase { } func testHandleIndentMultiLine() { - controller.config.behavior.indentOption = .tab + controller.configuration.behavior.indentOption = .tab let strings: [(NSString, Int)] = [ ("This is a test string\n", 0), ("With multiple lines\n", 22), @@ -99,7 +99,7 @@ final class TextViewControllerIndentTests: XCTestCase { } func testHandleInwardIndentMultiLine() { - controller.config.behavior.indentOption = .tab + controller.configuration.behavior.indentOption = .tab let strings: [(NSString, NSRange)] = [ ("\tThis is a test string\n", NSRange(location: 0, length: 0)), ("\tWith multiple lines\n", NSRange(location: 23, length: 0)), diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index b1cc6599a..992a2666c 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -49,17 +49,17 @@ final class TextViewControllerTests: XCTestCase { // MARK: Overscroll func test_editorOverScroll() throws { - controller.config.layout.editorOverscroll = 0 + controller.configuration.layout.editorOverscroll = 0 // editorOverscroll: 0 XCTAssertEqual(controller.textView.overscrollAmount, 0) - controller.config.layout.editorOverscroll = 0.5 + controller.configuration.layout.editorOverscroll = 0.5 // editorOverscroll: 0.5 XCTAssertEqual(controller.textView.overscrollAmount, 0.5) - controller.config.layout.editorOverscroll = 1.0 + controller.configuration.layout.editorOverscroll = 1.0 XCTAssertEqual(controller.textView.overscrollAmount, 1.0) } @@ -82,8 +82,9 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(lhs.left, rhs.left) } - controller.config.layout.editorOverscroll = 0 - controller.config.layout.contentInsets = nil + controller.configuration.layout.editorOverscroll = 0 + controller.configuration.layout.contentInsets = nil + controller.configuration.layout.additionalTextInsets = nil controller.reloadUI() // contentInsets: 0 @@ -91,14 +92,14 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.gutterView.frame.origin.y, 0) // contentInsets: 16 - controller.config.layout.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.configuration.layout.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) controller.reloadUI() try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) XCTAssertEqual(controller.gutterView.frame.origin.y, -16) // contentInsets: different - controller.config.layout.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) + controller.configuration.layout.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) controller.reloadUI() try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) @@ -106,8 +107,8 @@ final class TextViewControllerTests: XCTestCase { // contentInsets: 16 // editorOverscroll: 0.5 - controller.config.layout.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) - controller.config.layout.editorOverscroll = 0.5 // Should be ignored + controller.configuration.layout.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.configuration.layout.editorOverscroll = 0.5 // Should be ignored controller.reloadUI() try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) @@ -130,14 +131,14 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(lhs.left, rhs.left) } - controller.config.layout.contentInsets = nil - controller.config.layout.additionalTextInsets = nil + controller.configuration.layout.contentInsets = nil + controller.configuration.layout.additionalTextInsets = nil try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) XCTAssertEqual(controller.gutterView.frame.origin.y, 0) - controller.config.layout.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) - controller.config.layout.additionalTextInsets = NSEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) + controller.configuration.layout.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + controller.configuration.layout.additionalTextInsets = NSEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) controller.findViewController?.showFindPanel(animated: false) @@ -175,38 +176,38 @@ final class TextViewControllerTests: XCTestCase { controller.highlighter = nil // Insert 1 space - controller.config.behavior.indentOption = .spaces(count: 1) + controller.configuration.behavior.indentOption = .spaces(count: 1) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 2 spaces - controller.config.behavior.indentOption = .spaces(count: 2) + controller.configuration.behavior.indentOption = .spaces(count: 2) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 3 spaces - controller.config.behavior.indentOption = .spaces(count: 3) + controller.configuration.behavior.indentOption = .spaces(count: 3) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert 4 spaces - controller.config.behavior.indentOption = .spaces(count: 4) + controller.configuration.behavior.indentOption = .spaces(count: 4) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, " ") // Insert tab - controller.config.behavior.indentOption = .tab + controller.configuration.behavior.indentOption = .tab controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, "\t") // Insert lots of spaces - controller.config.behavior.indentOption = .spaces(count: 1000) + controller.configuration.behavior.indentOption = .spaces(count: 1000) controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") controller.textView.insertText("\t", replacementRange: .zero) XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) @@ -215,20 +216,20 @@ final class TextViewControllerTests: XCTestCase { func test_letterSpacing() { let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) - controller.config.appearance.letterSpacing = 1.0 + controller.configuration.appearance.letterSpacing = 1.0 XCTAssertEqual( controller.attributesFor(nil)[.kern]! as! CGFloat, (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 ) - controller.config.appearance.letterSpacing = 2.0 + controller.configuration.appearance.letterSpacing = 2.0 XCTAssertEqual( controller.attributesFor(nil)[.kern]! as! CGFloat, (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 ) - controller.config.appearance.letterSpacing = 1.0 + controller.configuration.appearance.letterSpacing = 1.0 } // MARK: Bracket Highlights @@ -241,27 +242,27 @@ final class TextViewControllerTests: XCTestCase { controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() let _ = controller.textView.becomeFirstResponder() - controller.config.appearance.bracketPairEmphasis = nil + controller.configuration.appearance.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 0, "Controller added bracket emphasis when setting is set to `nil`") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - controller.config.appearance.bracketPairEmphasis = .bordered(color: .black) + controller.configuration.appearance.bracketPairEmphasis = .bordered(color: .black) controller.textView.setNeedsDisplay() controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for bordered.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") - controller.config.appearance.bracketPairEmphasis = .underline(color: .black) + controller.configuration.appearance.bracketPairEmphasis = .underline(color: .black) controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for underline.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") - controller.config.appearance.bracketPairEmphasis = .flash + controller.configuration.appearance.bracketPairEmphasis = .flash controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssertEqual(getEmphasisCount(), 1, "Controller created more than one emphasis for flash animation.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) @@ -432,11 +433,11 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) - controller.config.peripherals.showMinimap = false + controller.configuration.peripherals.showMinimap = false XCTAssertTrue(controller.minimapView.isHidden) XCTAssertEqual(controller.textViewInsets.right, 0) - controller.config.peripherals.showMinimap = true + controller.configuration.peripherals.showMinimap = true XCTAssertFalse(controller.minimapView.isHidden) XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index b32db1b12..a43b20092 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -44,8 +44,8 @@ class MockHighlightProvider: HighlightProviding { enum Mock { class Delegate: TextViewDelegate { } - static func config() -> EditorConfig { - EditorConfig( + static func config() -> SourceEditorConfiguration { + SourceEditorConfiguration( appearance: .init( theme: theme(), font: .monospacedSystemFont(ofSize: 11, weight: .medium), diff --git a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift index 363cb9133..e7118fd9f 100644 --- a/Tests/CodeEditSourceEditorTests/TagEditingTests.swift +++ b/Tests/CodeEditSourceEditorTests/TagEditingTests.swift @@ -44,7 +44,7 @@ final class TagEditingTests: XCTestCase { } func test_tagCloseWithNewline() { - controller.config.behavior.indentOption = .spaces(count: 4) + controller.configuration.behavior.indentOption = .spaces(count: 4) controller.setText("\n
") controller.textView.selectionManager.setSelectedRange(NSRange(location: 21, length: 0)) @@ -58,7 +58,7 @@ final class TagEditingTests: XCTestCase { } func test_nestedClose() { - controller.config.behavior.indentOption = .spaces(count: 4) + controller.configuration.behavior.indentOption = .spaces(count: 4) controller.setText("\n
\n
\n
\n") controller.textView.selectionManager.setSelectedRange(NSRange(location: 30, length: 0)) @@ -74,7 +74,7 @@ final class TagEditingTests: XCTestCase { } func test_tagNotClose() { - controller.config.behavior.indentOption = .spaces(count: 1) + controller.configuration.behavior.indentOption = .spaces(count: 1) controller.setText("\n
\n
\n
\n") controller.textView.selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) @@ -127,7 +127,7 @@ final class TagEditingTests: XCTestCase { func test_TSXTagClose() { controller.language = .tsx - controller.config.behavior.indentOption = .spaces(count: 4) + controller.configuration.behavior.indentOption = .spaces(count: 4) controller.setText(""" const name = "CodeEdit" const element = ( From 8c166069a32c192c81ea8e0f151292179b55421b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 14:28:25 -0500 Subject: [PATCH 06/23] Update TextViewController.swift --- .../CodeEditSourceEditor/Controller/TextViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 602a8120e..9b713e6ea 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -181,7 +181,7 @@ public class TextViewController: NSViewController { cursorPositions: [CursorPosition], highlightProviders: [HighlightProviding] = [TreeSitterClient()], undoManager: CEUndoManager? = nil, - coordinators: [TextViewCoordinator] = [], + coordinators: [TextViewCoordinator] = [] ) { self.language = language self.configuration = config From ae2aa61df7b2ec3dadea40783e6befbd93ee917b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 14:31:05 -0500 Subject: [PATCH 07/23] Update SourceEditor.swift --- Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index e0fd3ab10..e146eec28 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -93,7 +93,7 @@ public struct SourceEditor: NSViewControllerRepresentable { cursorPositions: cursorPositions.wrappedValue, highlightProviders: context.coordinator.highlightProviders, undoManager: undoManager, - coordinators: coordinators, + coordinators: coordinators ) switch text { case .binding(let binding): From 71ea8662f36c783396f3518827fb885cd7752c32 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:07:23 -0500 Subject: [PATCH 08/23] Documentation --- .../Views/ContentView.swift | 17 +++++ README.md | 35 ++++++---- .../Controller/TextViewController.swift | 2 +- .../CodeEditSourceEditor.md | 29 -------- .../Documentation.docc/Documentation.md | 11 +-- .../Documentation.docc/SourceEditor.md | 68 +++++++++++++++++++ ...SourceEditorConfiguration+Appearance.swift | 15 ++++ .../SourceEditorConfiguration.swift | 25 ++++++- 8 files changed, 153 insertions(+), 49 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Documentation.docc/CodeEditSourceEditor.md create mode 100644 Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 829565423..7c900b4c5 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -103,6 +103,23 @@ struct ContentView: View { } } } + + func bruh() { + let editorController = TextViewController( + string: "let x = 10;", + language: .swift, + config: SourceEditorConfiguration( + appearance: .init(theme: theme, font: font), + behavior: .init(indentOption: indentOption), + layout: .init(editorOverscroll: editorOverscroll), + peripherals: .init(showMinimap: showMinimap) + ), + cursorPositions: [CursorPosition(line: 0, column: 0)], + highlightProviders: [], // Use the tree-sitter provider by default + undoManager: nil, + coordinators: [] // Optionally inject editing behavior or other plugins. + ) + } } #Preview { diff --git a/README.md b/README.md index 13b3f8b2a..85e280cb5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@

+ + + @@ -33,7 +36,7 @@ An Xcode-inspired code editor view written in Swift powered by tree-sitter for [ This package is fully documented [here](https://codeeditapp.github.io/CodeEditSourceEditor/documentation/codeeditsourceeditor/). -## Usage +## Usage (SwiftUI) ```swift import CodeEditSourceEditor @@ -41,37 +44,43 @@ import CodeEditSourceEditor struct ContentView: View { @State var text = "let x = 1.0" + + /// Automatically updates with cursor positions, or update the binding to set the user's cursors. + @State var cursorPositions: [CursorPosition] = [] + + /// Configure the editor's appearance, features, and editing behavior... @State var theme = EditorTheme(...) @State var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - @State var tabWidth = 4 - @State var lineHeight = 1.2 - @State var editorOverscroll = 0.3 + @State var indentOption = .spaces(count: 4) var body: some View { - CodeEditSourceEditor( + SourceEditor( $text, - language: .swift, - theme: $theme, - font: $font, - tabWidth: $tabWidth, - lineHeight: $lineHeight, - editorOverscroll: $editorOverscroll + language: language, + // Tons of customization options, with good defaults to get started quickly. + configuration: SourceEditorConfiguration( + appearance: .init(theme: theme, font: font), + behavior: .init(indentOption: indentOption) + ), + cursorPositions: $cursorPositions ) } } ``` +An AppKit API is also available. + ## Currently Supported Languages See this issue https://github.com/CodeEditApp/CodeEditLanguages/issues/10 on `CodeEditLanguages` for more information on supported languages. ## Dependencies -Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great work he's done! +Special thanks to [Matt Massicotte](https://bsky.app/profile/massicotte.org) for the great work he's done! | Package | Source | Author | | :- | :- | :- | -| `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://twitter.com/mattie) | +| `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://bsky.app/profile/massicotte.org) | ## License diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 9b713e6ea..d34b829ab 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -174,7 +174,7 @@ public class TextViewController: NSViewController { // MARK: Init - init( + public init( string: String, language: CodeLanguage, config: SourceEditorConfiguration, diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/CodeEditSourceEditor.md b/Sources/CodeEditSourceEditor/Documentation.docc/CodeEditSourceEditor.md deleted file mode 100644 index 64826f3bb..000000000 --- a/Sources/CodeEditSourceEditor/Documentation.docc/CodeEditSourceEditor.md +++ /dev/null @@ -1,29 +0,0 @@ -# ``CodeEditSourceEditor/CodeEditSourceEditor`` - -## Usage - -```swift -import CodeEditSourceEditor - -struct ContentView: View { - - @State var text = "let x = 1.0" - @State var theme = EditorTheme(...) - @State var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - @State var tabWidth = 4 - @State var lineHeight = 1.2 - @State var editorOverscroll = 0.3 - - var body: some View { - CodeEditSourceEditor( - $text, - language: .swift, - theme: $theme, - font: $font, - tabWidth: $tabWidth, - lineHeight: $lineHeight, - editorOverscroll: $editorOverscroll - ) - } -} -``` diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md index 4f7b6210c..dfc6a91b1 100644 --- a/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/Documentation.md @@ -20,11 +20,11 @@ See this issue [CodeEditLanguages#10](https://github.com/CodeEditApp/CodeEditLan ## Dependencies -Special thanks to [Matt Massicotte](https://twitter.com/mattie) for the great work he's done! +Special thanks to [Matt Massicotte](https://bsky.app/profile/massicotte.org) for the great work he's done! | Package | Source | Author | | :- | :- | :- | -| `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://twitter.com/mattie) | +| `SwiftTreeSitter` | [GitHub](https://github.com/ChimeHQ/SwiftTreeSitter) | [Matt Massicotte](https://bsky.app/profile/massicotte.org) | ## License @@ -34,9 +34,10 @@ Licensed under the [MIT license](https://github.com/CodeEditApp/CodeEdit/blob/ma ### Text View -- ``CodeEditSourceEditor/CodeEditSourceEditor`` -- ``TextViewController`` -- ``GutterView`` +- ``SourceEditor`` The SwiftUI API for the source editor. +- ``SourceEditorConfiguration`` Customize the source editor's behavior, layout, appearance, etc. +- ``TextViewController`` The AppKit view controller for the source editor. +- ``GutterView`` A view used to display line numbers and folding regions. ### Themes diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md b/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md new file mode 100644 index 000000000..4a977024b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md @@ -0,0 +1,68 @@ +# ``SourceEditor`` + +## Usage + +CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. + +#### SwiftUI + +```swift +import CodeEditSourceEditor + +struct ContentView: View { + + @State var text = "let x = 1.0" + // For large documents use (avoids SwiftUI inneficiency) + // var text: NSTextStorage + + /// Automatically updates with cursor positions, or update the binding to set the user's cursors. + @State var cursorPositions: [CursorPosition] = [] + + /// Configure the editor's appearance, features, and editing behavior... + @State var theme = EditorTheme(...) + @State var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + @State var indentOption = .spaces(count: 4) + @State var editorOverscroll = 0.3 + @State var showMinimap = true + + var body: some View { + SourceEditor( + $text, + language: language, + configuration: SourceEditorConfiguration( + appearance: .init(theme: theme, font: font), + behavior: .init(indentOption: indentOption), + layout: .init(editorOverscroll: editorOverscroll), + peripherals: .init(showMinimap: showMinimap) + ), + cursorPositions: $cursorPositions + ) + } +} +``` + +#### AppKit + +```swift +var theme = EditorTheme(...) +var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) +var indentOption = .spaces(count: 4) +var editorOverscroll = 0.3 +var showMinimap = true + +let editorController = TextViewController( + string: "let x = 10;", + language: .swift, + config: SourceEditorConfiguration( + appearance: .init(theme: theme, font: font), + behavior: .init(indentOption: indentOption), + layout: .init(editorOverscroll: editorOverscroll), + peripherals: .init(showMinimap: showMinimap) + ), + cursorPositions: [CursorPosition(line: 0, column: 0)], + highlightProviders: [], // Use the tree-sitter syntax highlighting provider by default + undoManager: nil, + coordinators: [] // Optionally inject editing behavior or other plugins. +) +``` + diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index da6c8e513..acca00f55 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -8,6 +8,7 @@ import AppKit extension SourceEditorConfiguration { + /// Configure the appearance of the editor. Font, theme, line height, etc. public struct Appearance: Equatable { /// The theme for syntax highlighting. public var theme: EditorTheme @@ -38,6 +39,20 @@ extension SourceEditorConfiguration { /// See ``BracketPairEmphasis`` for more information. Defaults to `.flash`. public var bracketPairEmphasis: BracketPairEmphasis? = .flash + /// Create a new appearance configuration object. + /// - Parameters: + /// - theme: The theme for syntax highlighting. + /// - useThemeBackground: Determines whether the editor uses the theme's background color, or a transparent + /// background color. + /// - font: The default font. + /// - lineHeightMultiple: The line height multiplier (e.g. `1.2`). + /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` + /// = 1/2 of a character's width between characters, etc. Defaults to `1.0`. + /// - wrapLines: Whether lines wrap to the width of the editor. + /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. + /// - tabWidth: The visual tab width in number of spaces. + /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. See + /// ``BracketPairEmphasis`` for more information. Defaults to `.flash`. public init( theme: EditorTheme, useThemeBackground: Bool = true, diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift index f198198ab..3743c8d35 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift @@ -7,12 +7,28 @@ import AppKit +/// Configuration object for the ``SourceEditor``. Determines appearance, behavior, layout and what features are +/// enabled (peripherals). +/// +/// To update the configuration, update the ``TextViewController/configuration`` property, or pass a value to the +/// ``SourceEditor`` SwiftUI API. Both methods will call the `didSetOnController` method on this type, which will +/// update the text controller as necessary for the new configuration. public struct SourceEditorConfiguration: Equatable { + /// Configure the appearance of the editor. Font, theme, line height, etc. public var appearance: Appearance + /// Configure the behavior of the editor. Indentation, edit-ability, select-ability, etc. public var behavior: Behavior - public var peripherals: Peripherals + /// Configure the layout of the editor. Content insets, etc. public var layout: Layout + /// Configure enabled features on the editor. Gutter (line numbers), minimap, etc. + public var peripherals: Peripherals + /// Create a new configuration object. + /// - Parameters: + /// - appearance: Configure the appearance of the editor. Font, theme, line height, etc. + /// - behavior: Configure the behavior of the editor. Indentation, edit-ability, select-ability, etc. + /// - layout: Configure the layout of the editor. Content insets, etc. + /// - peripherals: Configure enabled features on the editor. Gutter (line numbers), minimap, etc. public init( appearance: Appearance, behavior: Behavior = .init(), @@ -25,6 +41,13 @@ public struct SourceEditorConfiguration: Equatable { self.peripherals = peripherals } + /// Update the controller for a new configuration object. + /// + /// This object is the new one, the old one is passed in as an optional, assume that it's the first setup + /// when `oldConfig` is `nil`. + /// + /// This method should try to update a minimal number of properties as possible by checking for changes + /// before updating. @MainActor func didSetOnController(controller: TextViewController, oldConfig: SourceEditorConfiguration?) { appearance.didSetOnController(controller: controller, oldConfig: oldConfig?.appearance) From e915eea1dd574461f340ddef3899e8f9cf1a373a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:07:54 -0500 Subject: [PATCH 09/23] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 85e280cb5..3b194b2d7 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ This package is fully documented [here](https://codeeditapp.github.io/CodeEditSo import CodeEditSourceEditor struct ContentView: View { - @State var text = "let x = 1.0" /// Automatically updates with cursor positions, or update the binding to set the user's cursors. From 78468ef2f00f7b7042b02af03325a36950389a35 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:08:52 -0500 Subject: [PATCH 10/23] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3b194b2d7..67d1c025c 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@

- - + + From c23027b6ba72d9c6d87319c074dc61df3c5b0ba7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:21:33 -0500 Subject: [PATCH 11/23] Remove Test Code --- .../xcshareddata/swiftpm/Package.resolved | 113 ------------------ .../Views/ContentView.swift | 17 --- 2 files changed, 130 deletions(-) delete mode 100644 Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 243527a2a..000000000 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,113 +0,0 @@ -{ - "pins" : [ - { - "identity" : "codeeditlanguages", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditLanguages.git", - "state" : { - "revision" : "331d5dbc5fc8513be5848fce8a2a312908f36a11", - "version" : "0.1.20" - } - }, - { - "identity" : "codeeditsymbols", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditSymbols.git", - "state" : { - "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", - "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", - "location" : "https://github.com/ChimeHQ/Rearrange", - "state" : { - "revision" : "5ff7f3363f7a08f77e0d761e38e6add31c2136e1", - "version" : "1.8.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swiftlintplugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lukepistrol/SwiftLintPlugin", - "state" : { - "revision" : "ea6d3ca895b49910f790e98e4b4ca658e0fe490e", - "version" : "0.54.0" - } - }, - { - "identity" : "swifttreesitter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/SwiftTreeSitter.git", - "state" : { - "revision" : "36aa61d1b531f744f35229f010efba9c6d6cbbdd", - "version" : "0.9.0" - } - }, - { - "identity" : "textformation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/TextFormation", - "state" : { - "revision" : "b1ce9a14bd86042bba4de62236028dc4ce9db6a1", - "version" : "0.9.0" - } - }, - { - "identity" : "textstory", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/TextStory", - "state" : { - "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", - "version" : "0.9.0" - } - }, - { - "identity" : "tree-sitter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tree-sitter/tree-sitter", - "state" : { - "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", - "version" : "0.23.2" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", - "version" : "1.5.2" - } - } - ], - "version" : 2 -} diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 7c900b4c5..829565423 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -103,23 +103,6 @@ struct ContentView: View { } } } - - func bruh() { - let editorController = TextViewController( - string: "let x = 10;", - language: .swift, - config: SourceEditorConfiguration( - appearance: .init(theme: theme, font: font), - behavior: .init(indentOption: indentOption), - layout: .init(editorOverscroll: editorOverscroll), - peripherals: .init(showMinimap: showMinimap) - ), - cursorPositions: [CursorPosition(line: 0, column: 0)], - highlightProviders: [], // Use the tree-sitter provider by default - undoManager: nil, - coordinators: [] // Optionally inject editing behavior or other plugins. - ) - } } #Preview { From 0680885a6fd53af6f3f203d3c5886569ac54c7fe Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:32:35 -0500 Subject: [PATCH 12/23] Document Adding a Parameter, Finish Merge Invisible Chars API --- .../Views/ContentView.swift | 10 ++-- .../Views/StatusBar.swift | 9 ++-- .../Controller/TextViewController.swift | 20 ++++++- ...=> InvisibleCharactersConfiguration.swift} | 8 +-- .../InvisibleCharactersCoordinator.swift | 54 +++++++++++-------- .../SourceEditor/SourceEditor.swift | 2 +- ...ourceEditorConfiguration+Peripherals.swift | 23 +++++++- .../SourceEditorConfiguration.swift | 25 +++++++++ .../Controller/TextViewControllerTests.swift | 24 +++++---- Tests/CodeEditSourceEditorTests/Mock.swift | 2 +- 10 files changed, 129 insertions(+), 48 deletions(-) rename Sources/CodeEditSourceEditor/InvisibleCharacters/{InvisibleCharactersConfig.swift => InvisibleCharactersConfiguration.swift} (90%) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index f2396a777..4539c2018 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -30,7 +30,8 @@ struct ContentView: View { @AppStorage("showGutter") private var showGutter: Bool = true @AppStorage("showMinimap") private var showMinimap: Bool = true @AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false - @State private var invisibleCharactersConfig: InvisibleCharactersConfig = .empty + @State private var invisibleCharactersConfig: InvisibleCharactersConfiguration = .empty + @State private var warningCharacters: Set = [] @State private var isInLongParse = false @State private var settingsIsPresented: Bool = false @@ -61,7 +62,9 @@ struct ContentView: View { peripherals: .init( showGutter: showGutter, showMinimap: showMinimap, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + invisibleCharactersConfiguration: invisibleCharactersConfig, + warningCharacters: warningCharacters ) ), cursorPositions: $cursorPositions @@ -81,7 +84,8 @@ struct ContentView: View { indentOption: $indentOption, reformatAtColumn: $reformatAtColumn, showReformattingGuide: $showReformattingGuide, - invisibles: $invisibleCharactersConfig + invisibles: $invisibleCharactersConfig, + warningCharacters: $warningCharacters ) } .ignoresSafeArea() diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 1a6c52730..82c70e696 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -27,7 +27,8 @@ struct StatusBar: View { @Binding var indentOption: IndentOption @Binding var reformatAtColumn: Int @Binding var showReformattingGuide: Bool - @Binding var invisibles: InvisibleCharactersConfig + @Binding var invisibles: InvisibleCharactersConfiguration + @Binding var warningCharacters: Set var body: some View { HStack { @@ -63,16 +64,16 @@ struct StatusBar: View { "Warning Characters", isOn: Binding( get: { - !invisibles.warningCharacters.isEmpty + !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 + warningCharacters.insert(0x200B) // zero-width space } else { - invisibles.warningCharacters.removeAll() + warningCharacters.removeAll() } } ) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 72b170946..d6b6929f0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -32,6 +32,10 @@ public class TextViewController: NSViewController { /// The reformatting guide view var reformattingGuideView: ReformattingGuideView! + /// 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 + var minimapXConstraint: NSLayoutConstraint? var _undoManager: CEUndoManager! @@ -140,6 +144,17 @@ public class TextViewController: NSViewController { /// Toggle the visibility of the reformatting guide in the editor. public var showReformattingGuide: Bool { configuration.peripherals.showReformattingGuide } + /// Configuration for drawing invisible characters. + /// + /// See ``InvisibleCharactersConfiguration`` for more details. + public var invisibleCharactersConfiguration: InvisibleCharactersConfiguration { + configuration.peripherals.invisibleCharactersConfiguration + } + + /// 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 { configuration.peripherals.warningCharacters } + // MARK: - Internal Variables var textCoordinators: [WeakCoordinator] = [] @@ -177,17 +192,18 @@ public class TextViewController: NSViewController { public init( string: String, language: CodeLanguage, - config: SourceEditorConfiguration, + configuration: SourceEditorConfiguration, cursorPositions: [CursorPosition], highlightProviders: [HighlightProviding] = [TreeSitterClient()], undoManager: CEUndoManager? = nil, coordinators: [TextViewCoordinator] = [] ) { self.language = language - self.configuration = config + self.configuration = configuration self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self._undoManager = undoManager + self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator(configuration: configuration) super.init(nibName: nil, bundle: nil) diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfiguration.swift similarity index 90% rename from Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift rename to Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfiguration.swift index 5bb3f8100..ac7239f71 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfig.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersConfiguration.swift @@ -1,5 +1,5 @@ // -// InvisibleCharactersConfig.swift +// InvisibleCharactersConfiguration.swift // CodeEditSourceEditor // // Created by Khan Winter on 6/11/25. @@ -9,10 +9,10 @@ /// /// 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 { +public struct InvisibleCharactersConfiguration: Equatable, Hashable, Sendable, Codable { /// An empty configuration. - public static var empty: InvisibleCharactersConfig { - InvisibleCharactersConfig(showSpaces: false, showTabs: false, showLineEndings: false) + public static var empty: InvisibleCharactersConfiguration { + InvisibleCharactersConfiguration(showSpaces: false, showTabs: false, showLineEndings: false) } /// Set to true to draw spaces with a dot. diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift index a2042d5cc..7b7d7ee1b 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -19,7 +19,7 @@ import CodeEditTextView /// 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 { + var configuration: InvisibleCharactersConfiguration { didSet { updateTriggerCharacters() } @@ -60,14 +60,24 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { /// The set of characters the text view should trigger a call to ``invisibleStyle`` for. var triggerCharacters: Set = [] + convenience init(configuration: SourceEditorConfiguration) { + self.init( + configuration: configuration.peripherals.invisibleCharactersConfiguration, + warningCharacters: configuration.peripherals.warningCharacters, + indentOption: configuration.behavior.indentOption, + theme: configuration.appearance.theme, + font: configuration.appearance.font + ) + } + init( - config: InvisibleCharactersConfig, + configuration: InvisibleCharactersConfiguration, warningCharacters: Set, indentOption: IndentOption, theme: EditorTheme, font: NSFont ) { - self.config = config + self.configuration = configuration self.warningCharacters = warningCharacters self.indentOption = indentOption self.theme = theme @@ -83,7 +93,7 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { } private func updateTriggerCharacters() { - triggerCharacters = config.triggerCharacters().union(warningCharacters) + triggerCharacters = configuration.triggerCharacters().union(warningCharacters) } /// Determines if the textview should clear cached styles. @@ -103,17 +113,17 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { /// often and is cached in ``emphasizedFont``. func invisibleStyle(for character: UInt16, at range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? { switch character { - case InvisibleCharactersConfig.Symbols.space: + case InvisibleCharactersConfiguration.Symbols.space: return spacesStyle(range: range, lineRange: lineRange) - case InvisibleCharactersConfig.Symbols.tab: + case InvisibleCharactersConfiguration.Symbols.tab: return tabStyle() - case InvisibleCharactersConfig.Symbols.carriageReturn: + case InvisibleCharactersConfiguration.Symbols.carriageReturn: return carriageReturnStyle() - case InvisibleCharactersConfig.Symbols.lineFeed: + case InvisibleCharactersConfiguration.Symbols.lineFeed: return lineFeedStyle() - case InvisibleCharactersConfig.Symbols.paragraphSeparator: + case InvisibleCharactersConfiguration.Symbols.paragraphSeparator: return paragraphSeparatorStyle() - case InvisibleCharactersConfig.Symbols.lineSeparator: + case InvisibleCharactersConfiguration.Symbols.lineSeparator: return lineSeparatorStyle() default: return warningCharacterStyle(for: character) @@ -121,44 +131,44 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { } private func spacesStyle(range: NSRange, lineRange: NSRange) -> InvisibleCharacterStyle? { - guard config.showSpaces else { return nil } + guard configuration.showSpaces else { return nil } let locationInLine = range.location - lineRange.location let shouldBold = locationInLine % indentOption.charCount == indentOption.charCount - 1 return .replace( - replacementCharacter: config.spaceReplacement, + replacementCharacter: configuration.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) + guard configuration.showTabs else { return nil } + return .replace(replacementCharacter: configuration.tabReplacement, color: invisibleColor, font: font) } private func carriageReturnStyle() -> InvisibleCharacterStyle? { - guard config.showLineEndings else { return nil } - return .replace(replacementCharacter: config.carriageReturnReplacement, color: invisibleColor, font: font) + guard configuration.showLineEndings else { return nil } + return .replace(replacementCharacter: configuration.carriageReturnReplacement, color: invisibleColor, font: font) } private func lineFeedStyle() -> InvisibleCharacterStyle? { - guard config.showLineEndings else { return nil } - return .replace(replacementCharacter: config.lineFeedReplacement, color: invisibleColor, font: font) + guard configuration.showLineEndings else { return nil } + return .replace(replacementCharacter: configuration.lineFeedReplacement, color: invisibleColor, font: font) } private func paragraphSeparatorStyle() -> InvisibleCharacterStyle? { - guard config.showLineEndings else { return nil } + guard configuration.showLineEndings else { return nil } return .replace( - replacementCharacter: config.paragraphSeparatorReplacement, + replacementCharacter: configuration.paragraphSeparatorReplacement, color: invisibleColor, font: font ) } private func lineSeparatorStyle() -> InvisibleCharacterStyle? { - guard config.showLineEndings else { return nil } + guard configuration.showLineEndings else { return nil } return .replace( - replacementCharacter: config.lineSeparatorReplacement, + replacementCharacter: configuration.lineSeparatorReplacement, color: invisibleColor, font: font ) diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index e146eec28..7cdd05a75 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -89,7 +89,7 @@ public struct SourceEditor: NSViewControllerRepresentable { let controller = TextViewController( string: "", language: language, - config: configuration, + configuration: configuration, cursorPositions: cursorPositions.wrappedValue, highlightProviders: context.coordinator.highlightProviders, undoManager: undoManager, diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index 51801cfc4..7385271a0 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -16,14 +16,27 @@ extension SourceEditorConfiguration { /// Whether to show the reformatting guide. public var showReformattingGuide: Bool + /// Configuration for drawing invisible characters. + /// + /// See ``InvisibleCharactersConfiguration`` for more details. + public var invisibleCharactersConfiguration: InvisibleCharactersConfiguration + + /// 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( showGutter: Bool = true, showMinimap: Bool = true, - showReformattingGuide: Bool = false + showReformattingGuide: Bool = false, + invisibleCharactersConfiguration: InvisibleCharactersConfiguration = .empty, + warningCharacters: Set = [] ) { self.showGutter = showGutter self.showMinimap = showMinimap self.showReformattingGuide = showReformattingGuide + self.invisibleCharactersConfiguration = invisibleCharactersConfiguration + self.warningCharacters = warningCharacters } @MainActor @@ -45,6 +58,14 @@ extension SourceEditorConfiguration { controller.reformattingGuideView.updatePosition(in: controller) } + if oldConfig?.invisibleCharactersConfiguration != invisibleCharactersConfiguration { + controller.invisibleCharactersCoordinator.configuration = invisibleCharactersConfiguration + } + + if oldConfig?.warningCharacters != warningCharacters { + controller.invisibleCharactersCoordinator.warningCharacters = warningCharacters + } + if shouldUpdateInsets && controller.scrollView != nil { // Check for view existence controller.updateContentInsets() controller.updateTextInsets() diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift index 3743c8d35..504da2b0d 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift @@ -7,6 +7,31 @@ import AppKit +/// # Dev Note +/// +/// If you're looking to **add a parameter**, make sure you check these off: +/// - Determine what category it should go in. If what your adding changes often during editing (like cursor positions), +/// *it doesn't belong here*. These should be configurable, but (mostly) constant options (like the user's font). +/// - Add the parameter as a *public, mutable, variable* on that category. If it should have a default value, add it +/// to the variable. +/// - Add the parameter to that category's initializer, if it should have a default value, add it here too. +/// - Add a public variable to `TextViewController` in the "Config Helpers" mark with the same name and type. +/// The variable should be a passthrough variable to the configuration object. Eg: +/// ```swift +/// // in config: +/// var myVariable: Bool +/// +/// // in TextViewController +/// public var myVariable: Bool { configuration.category.myVariable } +/// ``` +/// - Add a new case to the category's `didSetOnController` method. You should check if the parameter has changed, and +/// update the text view controller as necessary to reflect the updated configuration. +/// - Add documentation in: +/// - The variable in the category. +/// - The category initializer. +/// - The passthrough variable in `TextViewController`. + + /// Configuration object for the ``SourceEditor``. Determines appearance, behavior, layout and what features are /// enabled (peripherals). /// diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index d2e3956a3..c3dbaaece 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -473,24 +473,28 @@ final class TextViewControllerTests: XCTestCase { func test_setInvisibleCharacterConfig() { controller.setText(" Hello world") - controller.indentOption = .spaces(count: 4) + controller.configuration.behavior.indentOption = .spaces(count: 4) - XCTAssertEqual(controller.invisibleCharactersConfig, .empty) + XCTAssertEqual(controller.invisibleCharactersConfiguration, .empty) - controller.invisibleCharactersConfig = .init(showSpaces: true, showTabs: true, showLineEndings: true) + controller.configuration.peripherals.invisibleCharactersConfiguration = .init( + showSpaces: true, + showTabs: true, + showLineEndings: true + ) XCTAssertEqual( - controller.invisibleCharactersConfig, + controller.invisibleCharactersConfiguration, .init(showSpaces: true, showTabs: true, showLineEndings: true) ) XCTAssertEqual( - controller.invisibleCharactersCoordinator.config, + controller.invisibleCharactersCoordinator.configuration, .init(showSpaces: true, showTabs: true, showLineEndings: true) ) // Should emphasize the 4th space XCTAssertEqual( controller.invisibleCharactersCoordinator.invisibleStyle( - for: InvisibleCharactersConfig.Symbols.space, + for: InvisibleCharactersConfiguration.Symbols.space, at: NSRange(location: 3, length: 1), lineRange: NSRange(location: 0, length: 15) ), @@ -502,7 +506,7 @@ final class TextViewControllerTests: XCTestCase { ) XCTAssertEqual( controller.invisibleCharactersCoordinator.invisibleStyle( - for: InvisibleCharactersConfig.Symbols.space, + for: InvisibleCharactersConfiguration.Symbols.space, at: NSRange(location: 4, length: 1), lineRange: NSRange(location: 0, length: 15) ), @@ -514,7 +518,7 @@ final class TextViewControllerTests: XCTestCase { ) if case .emphasize = controller.invisibleCharactersCoordinator.invisibleStyle( - for: InvisibleCharactersConfig.Symbols.tab, + for: InvisibleCharactersConfiguration.Symbols.tab, at: .zero, lineRange: .zero ) { @@ -525,9 +529,9 @@ final class TextViewControllerTests: XCTestCase { // MARK: - Warning Characters func test_setWarningCharacterConfig() { - XCTAssertEqual(controller.warningCharacters, []) + XCTAssertEqual(controller.warningCharacters, Set([])) - controller.warningCharacters = [0, 1] + controller.configuration.peripherals.warningCharacters = [0, 1] XCTAssertEqual(controller.warningCharacters, [0, 1]) XCTAssertEqual(controller.invisibleCharactersCoordinator.warningCharacters, [0, 1]) diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index a43b20092..58b6b9544 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -60,7 +60,7 @@ enum Mock { TextViewController( string: "", language: .html, - config: config(), + configuration: config(), cursorPositions: [], highlightProviders: [TreeSitterClient()] ) From d13e6451e3983b335ed552ffe296211ebf01b188 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:34:51 -0500 Subject: [PATCH 13/23] lint:fix --- .../Controller/TextViewController.swift | 2 +- .../InvisibleCharactersCoordinator.swift | 8 ++++++-- .../SourceEditorConfiguration.swift | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index d6b6929f0..6a0125a49 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -273,4 +273,4 @@ public class TextViewController: NSViewController { } localEvenMonitor = nil } -} // swiftlint:disable:this file_length +} diff --git a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift index 7b7d7ee1b..ac1a81296 100644 --- a/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift +++ b/Sources/CodeEditSourceEditor/InvisibleCharacters/InvisibleCharactersCoordinator.swift @@ -12,7 +12,7 @@ import CodeEditTextView /// /// 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``. +/// To keep lookups fast, does not use a computed property for ``InvisibleCharactersConfiguration/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, @@ -148,7 +148,11 @@ final class InvisibleCharactersCoordinator: InvisibleCharactersDelegate { private func carriageReturnStyle() -> InvisibleCharacterStyle? { guard configuration.showLineEndings else { return nil } - return .replace(replacementCharacter: configuration.carriageReturnReplacement, color: invisibleColor, font: font) + return .replace( + replacementCharacter: configuration.carriageReturnReplacement, + color: invisibleColor, + font: font + ) } private func lineFeedStyle() -> InvisibleCharacterStyle? { diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift index 504da2b0d..7e3c725ae 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration.swift @@ -31,7 +31,6 @@ import AppKit /// - The category initializer. /// - The passthrough variable in `TextViewController`. - /// Configuration object for the ``SourceEditor``. Determines appearance, behavior, layout and what features are /// enabled (peripherals). /// From 013860a24e98f6cb1c5ba9ed7faae6343601cea2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:49:29 -0500 Subject: [PATCH 14/23] Use Generic State Type, Track Scroll Position --- .../xcshareddata/swiftpm/Package.resolved | 9 --- .../Views/ContentView.swift | 8 ++- .../Views/StatusBar.swift | 14 ++++- Package.swift | 5 +- .../TextViewController+LoadView.swift | 2 + .../Controller/TextViewController.swift | 2 + .../SourceEditor+Coordinator.swift | 56 +++++++++++++---- .../SourceEditor/SourceEditor.swift | 62 ++++++++++++------- 8 files changed, 107 insertions(+), 51 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 ed390550e..c511a9f74 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "c045ffcf4d8b2904e7bd138cdeccda99cba3ab3c", - "version" : "0.11.2" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 4539c2018..bd3a6ead7 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -19,7 +19,9 @@ struct ContentView: View { @State private var language: CodeLanguage = .default @State private var theme: EditorTheme = .light - @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] + @State private var editorState = SourceEditorState( + cursorPositions: [CursorPosition(line: 1, column: 1)] + ) @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true @@ -67,7 +69,7 @@ struct ContentView: View { warningCharacters: warningCharacters ) ), - cursorPositions: $cursorPositions + state: $editorState ) .overlay(alignment: .bottom) { StatusBar( @@ -75,7 +77,7 @@ struct ContentView: View { document: $document, wrapLines: $wrapLines, useSystemCursor: $useSystemCursor, - cursorPositions: $cursorPositions, + state: $editorState, isInLongParse: $isInLongParse, language: $language, theme: $theme, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 82c70e696..c9eb003a6 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -18,7 +18,7 @@ struct StatusBar: View { @Binding var document: CodeEditSourceEditorExampleDocument @Binding var wrapLines: Bool @Binding var useSystemCursor: Bool - @Binding var cursorPositions: [CursorPosition] + @Binding var state: SourceEditorState @Binding var isInLongParse: Bool @Binding var language: CodeLanguage @Binding var theme: EditorTheme @@ -100,9 +100,9 @@ struct StatusBar: View { .controlSize(.small) Text("Parsing Document") } - } else { - Text(getLabel(cursorPositions)) } + scrollPosition + Text(getLabel(state.cursorPositions)) } .foregroundStyle(.secondary) Divider() @@ -133,6 +133,14 @@ struct StatusBar: View { } } + @ViewBuilder private var scrollPosition: some View { + Text("{") + + Text(Double(state.scrollPosition?.x ?? -1), format: .number.precision(.fractionLength(1))) + + Text(",") + + Text(Double(state.scrollPosition?.y ?? -1), format: .number.precision(.fractionLength(1))) + + Text("}") + } + private func detectLanguage(fileURL: URL?) -> CodeLanguage? { guard let fileURL else { return nil } return CodeLanguage.detectLanguageFrom( diff --git a/Package.swift b/Package.swift index 0335428f1..fb3effd48 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,9 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( - url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.2" + path: "../CodeEditTextView" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.11.2" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 410ec8337..7357c5630 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -103,6 +103,7 @@ extension TextViewController { guard let clipView = notification.object as? NSClipView else { return } self?.gutterView.needsDisplay = true self?.minimapXConstraint?.constant = clipView.bounds.origin.x + NotificationCenter.default.post(name: Self.scrollPositionDidUpdateNotification, object: self) } } @@ -115,6 +116,7 @@ extension TextViewController { self?.gutterView.needsDisplay = true self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) self?.updateTextInsets() + NotificationCenter.default.post(name: Self.scrollPositionDidUpdateNotification, object: self) } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 6a0125a49..8b43eb701 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -19,6 +19,8 @@ import TextFormation public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + // swiftlint:disable:next line_length + public static let scrollPositionDidUpdateNotification: Notification.Name = .init("TextViewController.scrollPositionDidUpdateNotification") // MARK: - Views and Child VCs diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index 3e74b5137..6b3731810 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -5,40 +5,61 @@ // Created by Khan Winter on 5/20/24. // -import Foundation +import AppKit import SwiftUI +import Combine import CodeEditTextView extension SourceEditor { @MainActor public class Coordinator: NSObject { - weak var controller: TextViewController? + private weak var controller: TextViewController? var isUpdatingFromRepresentable: Bool = false var isUpdateFromTextView: Bool = false var text: TextAPI - @Binding var cursorPositions: [CursorPosition] + @Binding var editorState: SourceEditorState private(set) var highlightProviders: [any HighlightProviding] - init(text: TextAPI, cursorPositions: Binding<[CursorPosition]>, highlightProviders: [any HighlightProviding]?) { + private var cancellables: Set = [] + + init(text: TextAPI, editorState: Binding, highlightProviders: [any HighlightProviding]?) { self.text = text - self._cursorPositions = cursorPositions + self._editorState = editorState self.highlightProviders = highlightProviders ?? [TreeSitterClient()] super.init() + } + + func setController(_ controller: TextViewController) { + self.controller = controller + // swiftlint:disable:this notification_center_detachment + NotificationCenter.default.removeObserver(self) NotificationCenter.default.addObserver( self, selector: #selector(textViewDidChangeText(_:)), name: TextView.textDidChangeNotification, - object: nil + object: controller.textView ) NotificationCenter.default.addObserver( self, selector: #selector(textControllerCursorsDidUpdate(_:)), name: TextViewController.cursorPositionUpdatedNotification, - object: nil + object: controller ) + + // Needs to be put on the main runloop or SwiftUI gets mad + NotificationCenter.default + .publisher( + for: TextViewController.scrollPositionDidUpdateNotification, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerScrollDidChange(notification) + } + .store(in: &cancellables) } func updateHighlightProviders(_ highlightProviders: [any HighlightProviding]?) { @@ -50,24 +71,33 @@ extension SourceEditor { } @objc func textViewDidChangeText(_ notification: Notification) { - guard let textView = notification.object as? TextView, - let controller, - controller.textView === textView else { + guard let textView = notification.object as? TextView else { return } + // A plain string binding is one-way (from this view, up the hierarchy) so it's not in the state binding if case .binding(let binding) = text { binding.wrappedValue = textView.string } } @objc func textControllerCursorsDidUpdate(_ notification: Notification) { - guard let notificationController = notification.object as? TextViewController, - notificationController === controller else { + guard let controller = notification.object as? TextViewController else { return } + updateState { $0.cursorPositions = controller.cursorPositions } + } + + func textControllerScrollDidChange(_ notification: Notification) { + guard let controller = notification.object as? TextViewController else { + return + } + updateState { $0.scrollPosition = controller.scrollView.contentView.bounds.origin } + } + + private func updateState(_ modifyCallback: (inout SourceEditorState) -> Void) { guard !isUpdatingFromRepresentable else { return } self.isUpdateFromTextView = true - cursorPositions = notificationController.cursorPositions + modifyCallback(&editorState) } deinit { diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 7cdd05a75..e89253144 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -10,9 +10,28 @@ import SwiftUI import CodeEditTextView import CodeEditLanguages +public struct SourceEditorState: Equatable, Hashable, Sendable, Codable { + public var cursorPositions: [CursorPosition] = [] + public var scrollPosition: CGPoint? + public var findText: String? + public var isShowingFindResults: Bool = false + + public init( + cursorPositions: [CursorPosition], + scrollPosition: CGPoint? = nil, + findText: String? = nil, + isShowingFindResults: Bool = false + ) { + self.cursorPositions = cursorPositions + self.scrollPosition = scrollPosition + self.findText = findText + self.isShowingFindResults = isShowingFindResults + } +} + /// A SwiftUI View that provides source editing functionality. public struct SourceEditor: NSViewControllerRepresentable { - package enum TextAPI { + enum TextAPI { case binding(Binding) case storage(NSTextStorage) } @@ -32,7 +51,7 @@ public struct SourceEditor: NSViewControllerRepresentable { _ text: Binding, language: CodeLanguage, configuration: SourceEditorConfiguration, - cursorPositions: Binding<[CursorPosition]>, + state: Binding, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -40,7 +59,7 @@ public struct SourceEditor: NSViewControllerRepresentable { self.text = .binding(text) self.language = language self.configuration = configuration - self.cursorPositions = cursorPositions + self._state = state self.highlightProviders = highlightProviders self.undoManager = undoManager self.coordinators = coordinators @@ -61,7 +80,7 @@ public struct SourceEditor: NSViewControllerRepresentable { _ text: NSTextStorage, language: CodeLanguage, configuration: SourceEditorConfiguration, - cursorPositions: Binding<[CursorPosition]>, + state: Binding, highlightProviders: [any HighlightProviding]? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -69,19 +88,19 @@ public struct SourceEditor: NSViewControllerRepresentable { self.text = .storage(text) self.language = language self.configuration = configuration - self.cursorPositions = cursorPositions + self._state = state self.highlightProviders = highlightProviders self.undoManager = undoManager self.coordinators = coordinators } - package var text: TextAPI - private var language: CodeLanguage - private var configuration: SourceEditorConfiguration - package var cursorPositions: Binding<[CursorPosition]> - private var highlightProviders: [any HighlightProviding]? - private var undoManager: CEUndoManager? - package var coordinators: [any TextViewCoordinator] + var text: TextAPI + var language: CodeLanguage + var configuration: SourceEditorConfiguration + @Binding var state: SourceEditorState + var highlightProviders: [any HighlightProviding]? + var undoManager: CEUndoManager? + var coordinators: [any TextViewCoordinator] public typealias NSViewControllerType = TextViewController @@ -90,7 +109,7 @@ public struct SourceEditor: NSViewControllerRepresentable { string: "", language: language, configuration: configuration, - cursorPositions: cursorPositions.wrappedValue, + cursorPositions: state.cursorPositions, highlightProviders: context.coordinator.highlightProviders, undoManager: undoManager, coordinators: coordinators @@ -104,28 +123,29 @@ public struct SourceEditor: NSViewControllerRepresentable { if controller.textView == nil { controller.loadView() } - if !cursorPositions.isEmpty { - controller.setCursorPositions(cursorPositions.wrappedValue) + if !state.cursorPositions.isEmpty { + controller.setCursorPositions(state.cursorPositions) } - context.coordinator.controller = controller + context.coordinator.setController(controller) return controller } public func makeCoordinator() -> Coordinator { - Coordinator(text: text, cursorPositions: cursorPositions, highlightProviders: highlightProviders) + Coordinator(text: text, editorState: $state, highlightProviders: highlightProviders) } public func updateNSViewController(_ controller: TextViewController, context: Context) { context.coordinator.updateHighlightProviders(highlightProviders) - if !context.coordinator.isUpdateFromTextView { + if context.coordinator.isUpdateFromTextView { + context.coordinator.isUpdateFromTextView = false + } else { // Prevent infinite loop of update notifications context.coordinator.isUpdatingFromRepresentable = true - controller.setCursorPositions(cursorPositions.wrappedValue) +// controller.setCursorPositions(state.cursorPositions) + // TODO: Set scroll position, find text, etc. context.coordinator.isUpdatingFromRepresentable = false - } else { - context.coordinator.isUpdateFromTextView = false } // Set this no matter what to avoid having to compare object pointers. From 662a23151bcb20457fb542583821b39532cca7b8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:08:42 -0500 Subject: [PATCH 15/23] Add Find Toggle, Find Text to State --- .../Views/StatusBar.swift | 47 +++++++++++++++-- .../Find/FindViewController+Toggle.swift | 10 ++++ .../Find/ViewModel/FindPanelViewModel.swift | 5 ++ .../SourceEditor+Coordinator.swift | 47 +++++++++++++++-- .../SourceEditor/SourceEditor.swift | 52 +++++++++++-------- .../SourceEditor/SourceEditorState.swift | 27 ++++++++++ 6 files changed, 158 insertions(+), 30 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index c9eb003a6..f7b41d0b4 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -105,6 +105,18 @@ struct StatusBar: View { Text(getLabel(state.cursorPositions)) } .foregroundStyle(.secondary) + + Divider() + .frame(height: 12) + + Button { + state.findPanelVisible.toggle() + } label: { + Text(state.findPanelVisible ? "Hide" : "Show") + Text(" Find") + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + Divider() .frame(height: 12) LanguagePicker(language: $language) @@ -133,12 +145,37 @@ struct StatusBar: View { } } + var formatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 0 + formatter.allowsFloats = true + return formatter + } + @ViewBuilder private var scrollPosition: some View { - Text("{") - + Text(Double(state.scrollPosition?.x ?? -1), format: .number.precision(.fractionLength(1))) - + Text(",") - + Text(Double(state.scrollPosition?.y ?? -1), format: .number.precision(.fractionLength(1))) - + Text("}") + HStack(spacing: 0) { + Text("{") + TextField( + "", + value: Binding(get: { Double(state.scrollPosition?.x ?? 0.0) }, set: { state.scrollPosition?.x = $0 }), + formatter: formatter + ) + .textFieldStyle(.plain) + .labelsHidden() + .fixedSize() + Text(",") + TextField( + "", + value: Binding(get: { Double(state.scrollPosition?.y ?? 0.0) }, set: { state.scrollPosition?.y = $0 }), + formatter: formatter + ) + .textFieldStyle(.plain) + .labelsHidden() + .fixedSize() + Text("}") + } } private func detectLanguage(fileURL: URL?) -> CodeLanguage? { diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index bfea53c92..84bc16164 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -44,6 +44,11 @@ extension FindViewController { viewModel.isFocused = true findPanel.addEventMonitor() + + NotificationCenter.default.post( + name: FindPanelViewModel.findPanelDidToggleNotification, + object: viewModel.target + ) } /// Hide the find panel @@ -70,6 +75,11 @@ extension FindViewController { if let target = viewModel.target { _ = target.findPanelTargetView.window?.makeFirstResponder(target.findPanelTargetView) } + + NotificationCenter.default.post( + name: FindPanelViewModel.findPanelDidToggleNotification, + object: viewModel.target + ) } /// Performs an animation with a completion handler, conditionally animating the changes. diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index 6bbb02816..103d880d4 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -10,6 +10,9 @@ import Combine import CodeEditTextView class FindPanelViewModel: ObservableObject { + static let findPanelTextDidChangeNotification = Notification.Name("FindPanelViewModel.findPanelTextDidChange") + static let findPanelDidToggleNotification = Notification.Name("FindPanelViewModel.findPanelDidToggle") + weak var target: FindPanelTarget? var dismiss: (() -> Void)? @@ -99,5 +102,7 @@ class FindPanelViewModel: ObservableObject { // Clear existing emphases before performing new find target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) find() + + NotificationCenter.default.post(name: Self.findPanelTextDidChangeNotification, object: target) } } diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index 6b3731810..dccdca4b6 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -32,7 +32,7 @@ extension SourceEditor { func setController(_ controller: TextViewController) { self.controller = controller - // swiftlint:disable:this notification_center_detachment + // swiftlint:disable:next notification_center_detachment NotificationCenter.default.removeObserver(self) NotificationCenter.default.addObserver( @@ -49,7 +49,7 @@ extension SourceEditor { object: controller ) - // Needs to be put on the main runloop or SwiftUI gets mad + // Needs to be put on the main runloop or SwiftUI gets mad about updating state during view updates. NotificationCenter.default .publisher( for: TextViewController.scrollPositionDidUpdateNotification, @@ -60,6 +60,28 @@ extension SourceEditor { self?.textControllerScrollDidChange(notification) } .store(in: &cancellables) + + NotificationCenter.default + .publisher( + for: FindPanelViewModel.findPanelTextDidChangeNotification, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerFindTextDidChange(notification) + } + .store(in: &cancellables) + + NotificationCenter.default + .publisher( + for: FindPanelViewModel.findPanelDidToggleNotification, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerFindDidToggle(notification) + } + .store(in: &cancellables) } func updateHighlightProviders(_ highlightProviders: [any HighlightProviding]?) { @@ -91,7 +113,26 @@ extension SourceEditor { guard let controller = notification.object as? TextViewController else { return } - updateState { $0.scrollPosition = controller.scrollView.contentView.bounds.origin } + let currentPosition = controller.scrollView.contentView.bounds.origin + if editorState.scrollPosition != currentPosition { + updateState { $0.scrollPosition = currentPosition } + } + } + + func textControllerFindTextDidChange(_ notification: Notification) { + guard let controller = notification.object as? TextViewController, + let findModel = controller.findViewController?.viewModel else { + return + } + updateState { $0.findText = findModel.findText } + } + + func textControllerFindDidToggle(_ notification: Notification) { + guard let controller = notification.object as? TextViewController, + let findModel = controller.findViewController?.viewModel else { + return + } + updateState { $0.findPanelVisible = findModel.isShowingFindPanel } } private func updateState(_ modifyCallback: (inout SourceEditorState) -> Void) { diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index e89253144..0c410377e 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -10,25 +10,6 @@ import SwiftUI import CodeEditTextView import CodeEditLanguages -public struct SourceEditorState: Equatable, Hashable, Sendable, Codable { - public var cursorPositions: [CursorPosition] = [] - public var scrollPosition: CGPoint? - public var findText: String? - public var isShowingFindResults: Bool = false - - public init( - cursorPositions: [CursorPosition], - scrollPosition: CGPoint? = nil, - findText: String? = nil, - isShowingFindResults: Bool = false - ) { - self.cursorPositions = cursorPositions - self.scrollPosition = scrollPosition - self.findText = findText - self.isShowingFindResults = isShowingFindResults - } -} - /// A SwiftUI View that provides source editing functionality. public struct SourceEditor: NSViewControllerRepresentable { enum TextAPI { @@ -137,14 +118,41 @@ public struct SourceEditor: NSViewControllerRepresentable { public func updateNSViewController(_ controller: TextViewController, context: Context) { context.coordinator.updateHighlightProviders(highlightProviders) + print( + context.coordinator.isUpdateFromTextView, + state.findPanelVisible, + controller.findViewController?.viewModel.isShowingFindPanel ?? false + ) + // Prevent infinite loop of update notifications if context.coordinator.isUpdateFromTextView { context.coordinator.isUpdateFromTextView = false } else { - // Prevent infinite loop of update notifications context.coordinator.isUpdatingFromRepresentable = true -// controller.setCursorPositions(state.cursorPositions) - // TODO: Set scroll position, find text, etc. + controller.setCursorPositions(state.cursorPositions) + + if let scrollPosition = state.scrollPosition { + controller.scrollView.scroll(controller.scrollView.contentView, to: scrollPosition) + controller.scrollView.reflectScrolledClipView(controller.scrollView.contentView) + controller.gutterView.needsDisplay = true + } + + if let findText = state.findText { + controller.findViewController?.viewModel.findText = findText + } + + if let findController = controller.findViewController, + findController.viewModel.isShowingFindPanel != state.findPanelVisible { + // Needs to be on the next runloop, not many great ways to do this besides a dispatch... + DispatchQueue.main.async { + if state.findPanelVisible { + findController.showFindPanel() + } else { + findController.hideFindPanel() + } + } + } + context.coordinator.isUpdatingFromRepresentable = false } diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift new file mode 100644 index 000000000..01b80d434 --- /dev/null +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift @@ -0,0 +1,27 @@ +// +// SourceEditorState.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/19/25. +// + +import AppKit + +public struct SourceEditorState: Equatable, Hashable, Sendable, Codable { + public var cursorPositions: [CursorPosition] = [] + public var scrollPosition: CGPoint? + public var findText: String? + public var findPanelVisible: Bool = false + + public init( + cursorPositions: [CursorPosition], + scrollPosition: CGPoint? = nil, + findText: String? = nil, + findPanelVisible: Bool = false + ) { + self.cursorPositions = cursorPositions + self.scrollPosition = scrollPosition + self.findText = findText + self.findPanelVisible = findPanelVisible + } +} From cce41f770976cd45af74b7d498ed92482fadde6f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:14:40 -0500 Subject: [PATCH 16/23] Validate Find Text Updating --- .../CodeEditSourceEditorExample/Views/StatusBar.swift | 6 ++++++ .../CodeEditSourceEditor/SourceEditor/SourceEditor.swift | 7 +------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index f7b41d0b4..262f46756 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -109,6 +109,12 @@ struct StatusBar: View { Divider() .frame(height: 12) + Text(state.findText ?? "") + .frame(maxWidth: 30) + .lineLimit(1) + .truncationMode(.head) + .foregroundStyle(.secondary) + Button { state.findPanelVisible.toggle() } label: { diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 0c410377e..e1a6c2205 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -118,11 +118,6 @@ public struct SourceEditor: NSViewControllerRepresentable { public func updateNSViewController(_ controller: TextViewController, context: Context) { context.coordinator.updateHighlightProviders(highlightProviders) - print( - context.coordinator.isUpdateFromTextView, - state.findPanelVisible, - controller.findViewController?.viewModel.isShowingFindPanel ?? false - ) // Prevent infinite loop of update notifications if context.coordinator.isUpdateFromTextView { @@ -137,7 +132,7 @@ public struct SourceEditor: NSViewControllerRepresentable { controller.gutterView.needsDisplay = true } - if let findText = state.findText { + if let findText = state.findText, findText != state.findText { controller.findViewController?.viewModel.findText = findText } From 7fc27cf6c10b656b2bf24ba69d0d6888f54b37ba Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:52:11 -0500 Subject: [PATCH 17/23] Fix some missing configuration, remove duplicates --- .../TextViewController+LoadView.swift | 1 - .../TextViewController+ReloadUI.swift | 11 +--- .../TextViewController+StyleViews.swift | 56 ---------------- ...SourceEditorConfiguration+Appearance.swift | 66 +++++++++++++++---- .../SourceEditorConfiguration+Behavior.swift | 6 ++ 5 files changed, 63 insertions(+), 77 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 410ec8337..2a6271201 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -42,7 +42,6 @@ extension TextViewController { styleTextView() styleScrollView() - styleGutterView() styleMinimapView() setUpHighlighter() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift index 659c95b71..8775f828f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift @@ -9,20 +9,13 @@ import AppKit extension TextViewController { func reloadUI() { - textView.isEditable = configuration.behavior.isEditable - textView.isSelectable = configuration.behavior.isSelectable + configuration.didSetOnController(controller: self, oldConfig: nil) styleScrollView() styleTextView() - styleGutterView() - highlighter?.invalidate() minimapView.updateContentViewHeight() minimapView.updateDocumentVisibleViewPosition() - - // Update reformatting guide position - if let guideView = textView.subviews.first(where: { $0 is ReformattingGuideView }) as? ReformattingGuideView { - guideView.updatePosition(in: self) - } + reformattingGuideView.updatePosition(in: self) } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index b0d9e83bc..3f106b010 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -21,55 +21,6 @@ extension TextViewController { package func styleTextView() { textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false - textView.selectionManager.selectionBackgroundColor = theme.selection - textView.selectionManager.selectedLineBackgroundColor = getThemeBackground() - textView.selectionManager.highlightSelectedLine = configuration.behavior.isEditable - textView.selectionManager.insertionPointColor = theme.insertionPoint - textView.enclosingScrollView?.backgroundColor = if useThemeBackground { - theme.background - } else { - .clear - } - textView.overscrollAmount = editorOverscroll - paragraphStyle = generateParagraphStyle() - textView.typingAttributes = attributesFor(nil) - } - - /// Finds the preferred use theme background. - /// - Returns: The background color to use. - private func getThemeBackground() -> NSColor { - if useThemeBackground { - return theme.lineHighlight - } - - if systemAppearance == .darkAqua { - return NSColor.quaternaryLabelColor - } - - return NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - } - - /// Style the gutter view. - package func styleGutterView() { - gutterView.selectedLineColor = if useThemeBackground { - theme.lineHighlight - } else if systemAppearance == .darkAqua { - NSColor.quaternaryLabelColor - } else { - NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - } - gutterView.highlightSelectedLines = configuration.behavior.isEditable - gutterView.font = font.rulerFont - gutterView.backgroundColor = if useThemeBackground { - theme.background - } else { - .windowBackgroundColor - } - if configuration.behavior.isEditable == false { - gutterView.selectedLineTextColor = nil - gutterView.selectedLineColor = .clear - } - gutterView.isHidden = !showGutter } /// Style the scroll view. @@ -77,18 +28,11 @@ extension TextViewController { scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.contentView.postsFrameChangedNotifications = true scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = !wrapLines scrollView.scrollerStyle = .overlay } package func styleMinimapView() { minimapView.postsFrameChangedNotifications = true - minimapView.isHidden = !showMinimap - } - - package func styleReformattingGuideView() { - reformattingGuideView.updatePosition(in: self) - reformattingGuideView.isHidden = !showReformattingGuide } /// Updates all relevant content insets including the find panel, scroll view, minimap and gutter position. diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift index acca00f55..6ca03f2a7 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Appearance.swift @@ -85,20 +85,13 @@ extension SourceEditorConfiguration { if oldConfig?.font != font { controller.textView.font = font + controller.textView.typingAttributes = controller.attributesFor(nil) + controller.gutterView.font = font.rulerFont needsHighlighterInvalidation = true } - if oldConfig?.theme != theme { - controller.textView.layoutManager.setNeedsLayout() - controller.textView.textStorage.setAttributes( - controller.attributesFor(nil), - range: NSRange(location: 0, length: controller.textView.textStorage.length) - ) - controller.textView.selectionManager.selectedLineBackgroundColor = theme.selection - controller.gutterView.textColor = theme.text.color.withAlphaComponent(0.35) - controller.gutterView.selectedLineTextColor = theme.text.color - controller.minimapView.setTheme(theme) - controller.reformattingGuideView?.theme = theme + if oldConfig?.theme != theme || oldConfig?.useThemeBackground != useThemeBackground { + updateControllerNewTheme(controller: controller) needsHighlighterInvalidation = true } @@ -141,5 +134,56 @@ extension SourceEditorConfiguration { controller.highlighter?.invalidate() } } + + private func updateControllerNewTheme(controller: TextViewController) { + controller.textView.layoutManager.setNeedsLayout() + controller.textView.textStorage.setAttributes( + controller.attributesFor(nil), + range: NSRange(location: 0, length: controller.textView.textStorage.length) + ) + controller.textView.selectionManager.selectionBackgroundColor = theme.selection + controller.textView.selectionManager.selectedLineBackgroundColor = getThemeBackground( + systemAppearance: controller.systemAppearance + ) + controller.textView.selectionManager.insertionPointColor = theme.insertionPoint + controller.textView.enclosingScrollView?.backgroundColor = if useThemeBackground { + theme.background + } else { + .clear + } + + controller.gutterView.textColor = theme.text.color.withAlphaComponent(0.35) + controller.gutterView.selectedLineTextColor = theme.text.color + controller.gutterView.selectedLineColor = if useThemeBackground { + theme.lineHighlight + } else if controller.systemAppearance == .darkAqua { + NSColor.quaternaryLabelColor + } else { + NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + } + controller.gutterView.backgroundColor = if useThemeBackground { + theme.background + } else { + .windowBackgroundColor + } + + controller.minimapView.setTheme(theme) + controller.reformattingGuideView?.theme = theme + controller.textView.typingAttributes = controller.attributesFor(nil) + } + + /// Finds the preferred use theme background. + /// - Returns: The background color to use. + private func getThemeBackground(systemAppearance: NSAppearance.Name?) -> NSColor { + if useThemeBackground { + return theme.lineHighlight + } + + if systemAppearance == .darkAqua { + return NSColor.quaternaryLabelColor + } + + return NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + } } } diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift index 4eab9b936..28255f14d 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Behavior.swift @@ -36,6 +36,12 @@ extension SourceEditorConfiguration { func didSetOnController(controller: TextViewController, oldConfig: Behavior?) { if oldConfig?.isEditable != isEditable { controller.textView.isEditable = isEditable + controller.textView.selectionManager.highlightSelectedLine = isEditable + controller.gutterView.highlightSelectedLines = isEditable + if !isEditable { + controller.gutterView.selectedLineTextColor = nil + controller.gutterView.selectedLineColor = .clear + } } if oldConfig?.isSelectable != isSelectable { From e4394257da4ad897d0e15e91d9729427d2630fa8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:54:01 -0500 Subject: [PATCH 18/23] Completely Optional State Variables --- Package.resolved | 9 --------- .../SourceEditor/SourceEditor.swift | 17 ++++++++++------- .../SourceEditor/SourceEditorState.swift | 6 +++--- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Package.resolved b/Package.resolved index 39dbd018e..db462e1ec 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,15 +18,6 @@ "version" : "0.2.3" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "c045ffcf4d8b2904e7bd138cdeccda99cba3ab3c", - "version" : "0.11.2" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index e1a6c2205..07048b2e9 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -90,7 +90,7 @@ public struct SourceEditor: NSViewControllerRepresentable { string: "", language: language, configuration: configuration, - cursorPositions: state.cursorPositions, + cursorPositions: state.cursorPositions ?? [], highlightProviders: context.coordinator.highlightProviders, undoManager: undoManager, coordinators: coordinators @@ -104,8 +104,8 @@ public struct SourceEditor: NSViewControllerRepresentable { if controller.textView == nil { controller.loadView() } - if !state.cursorPositions.isEmpty { - controller.setCursorPositions(state.cursorPositions) + if !(state.cursorPositions?.isEmpty ?? true) { + controller.setCursorPositions(state.cursorPositions ?? []) } context.coordinator.setController(controller) @@ -124,7 +124,9 @@ public struct SourceEditor: NSViewControllerRepresentable { context.coordinator.isUpdateFromTextView = false } else { context.coordinator.isUpdatingFromRepresentable = true - controller.setCursorPositions(state.cursorPositions) + if let cursorPositions = state.cursorPositions { + controller.setCursorPositions(cursorPositions) + } if let scrollPosition = state.scrollPosition { controller.scrollView.scroll(controller.scrollView.contentView, to: scrollPosition) @@ -136,11 +138,12 @@ public struct SourceEditor: NSViewControllerRepresentable { controller.findViewController?.viewModel.findText = findText } - if let findController = controller.findViewController, - findController.viewModel.isShowingFindPanel != state.findPanelVisible { + if let findPanelVisible = state.findPanelVisible, + let findController = controller.findViewController, + findController.viewModel.isShowingFindPanel != findPanelVisible { // Needs to be on the next runloop, not many great ways to do this besides a dispatch... DispatchQueue.main.async { - if state.findPanelVisible { + if findPanelVisible { findController.showFindPanel() } else { findController.hideFindPanel() diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift index 01b80d434..27a2e8c96 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift @@ -8,16 +8,16 @@ import AppKit public struct SourceEditorState: Equatable, Hashable, Sendable, Codable { - public var cursorPositions: [CursorPosition] = [] + public var cursorPositions: [CursorPosition]? public var scrollPosition: CGPoint? public var findText: String? - public var findPanelVisible: Bool = false + public var findPanelVisible: Bool? public init( cursorPositions: [CursorPosition], scrollPosition: CGPoint? = nil, findText: String? = nil, - findPanelVisible: Bool = false + findPanelVisible: Bool? = nil ) { self.cursorPositions = cursorPositions self.scrollPosition = scrollPosition From 994c712e542a38cedf267cc8c7eb97c0da49d292 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:55:22 -0500 Subject: [PATCH 19/23] Move State Struct --- .../{SourceEditor => SourceEditorState}/SourceEditorState.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/CodeEditSourceEditor/{SourceEditor => SourceEditorState}/SourceEditorState.swift (100%) diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift b/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift similarity index 100% rename from Sources/CodeEditSourceEditor/SourceEditor/SourceEditorState.swift rename to Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift From 25ddd97b2e8aa2fdb7552ff50f767cb0fa1ac5f9 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:13:38 -0500 Subject: [PATCH 20/23] Add Replace Text State --- Package.swift | 5 +++-- .../Find/PanelView/FindPanelView.swift | 3 +++ .../Find/ViewModel/FindPanelViewModel.swift | 6 ++++++ .../SourceEditor+Coordinator.swift | 19 +++++++++++++++++++ .../SourceEditor/SourceEditor.swift | 11 ++++++++--- .../SourceEditorState/SourceEditorState.swift | 5 ++++- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Package.swift b/Package.swift index 3d8026922..5a654921a 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,9 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( - url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.3" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.11.3" + path: "../CodeEditTextView" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index c32be4b8b..8d859b183 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -60,6 +60,9 @@ struct FindPanelView: View { .onChange(of: viewModel.findText) { _ in viewModel.findTextDidChange() } + .onChange(of: viewModel.replaceText) { _ in + viewModel.replaceTextDidChange() + } .onChange(of: viewModel.wrapAround) { _ in viewModel.find() } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index 103d880d4..c26fc48da 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -11,6 +11,8 @@ import CodeEditTextView class FindPanelViewModel: ObservableObject { static let findPanelTextDidChangeNotification = Notification.Name("FindPanelViewModel.findPanelTextDidChange") + // swiftlint:disable:next line_length + static let findPanelReplaceTextDidChangeNotification = Notification.Name("FindPanelViewModel.findPanelReplaceTextDidChange") static let findPanelDidToggleNotification = Notification.Name("FindPanelViewModel.findPanelDidToggle") weak var target: FindPanelTarget? @@ -105,4 +107,8 @@ class FindPanelViewModel: ObservableObject { NotificationCenter.default.post(name: Self.findPanelTextDidChangeNotification, object: target) } + + func replaceTextDidChange() { + NotificationCenter.default.post(name: Self.findPanelReplaceTextDidChangeNotification, object: target) + } } diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index dccdca4b6..2969b17a9 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -72,6 +72,17 @@ extension SourceEditor { } .store(in: &cancellables) + NotificationCenter.default + .publisher( + for: FindPanelViewModel.findPanelReplaceTextDidChangeNotification, + object: controller + ) + .receive(on: RunLoop.main) + .sink { [weak self] notification in + self?.textControllerReplaceTextDidChange(notification) + } + .store(in: &cancellables) + NotificationCenter.default .publisher( for: FindPanelViewModel.findPanelDidToggleNotification, @@ -127,6 +138,14 @@ extension SourceEditor { updateState { $0.findText = findModel.findText } } + func textControllerReplaceTextDidChange(_ notification: Notification) { + guard let controller = notification.object as? TextViewController, + let findModel = controller.findViewController?.viewModel else { + return + } + updateState { $0.replaceText = findModel.replaceText } + } + func textControllerFindDidToggle(_ notification: Notification) { guard let controller = notification.object as? TextViewController, let findModel = controller.findViewController?.viewModel else { diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 07048b2e9..89d098b6b 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -124,20 +124,25 @@ public struct SourceEditor: NSViewControllerRepresentable { context.coordinator.isUpdateFromTextView = false } else { context.coordinator.isUpdatingFromRepresentable = true - if let cursorPositions = state.cursorPositions { + if let cursorPositions = state.cursorPositions, cursorPositions != state.cursorPositions { controller.setCursorPositions(cursorPositions) } - if let scrollPosition = state.scrollPosition { + if let scrollPosition = state.scrollPosition, scrollPosition != state.scrollPosition { controller.scrollView.scroll(controller.scrollView.contentView, to: scrollPosition) controller.scrollView.reflectScrolledClipView(controller.scrollView.contentView) controller.gutterView.needsDisplay = true + NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: controller.textView) } - if let findText = state.findText, findText != state.findText { + if let findText = state.findText, findText != controller.findViewController?.viewModel.findText { controller.findViewController?.viewModel.findText = findText } + if let replaceText = state.replaceText, replaceText != controller.findViewController?.viewModel.replaceText { + controller.findViewController?.viewModel.replaceText = replaceText + } + if let findPanelVisible = state.findPanelVisible, let findController = controller.findViewController, findController.viewModel.isShowingFindPanel != findPanelVisible { diff --git a/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift b/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift index 27a2e8c96..933ef9dd2 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorState/SourceEditorState.swift @@ -11,17 +11,20 @@ public struct SourceEditorState: Equatable, Hashable, Sendable, Codable { public var cursorPositions: [CursorPosition]? public var scrollPosition: CGPoint? public var findText: String? + public var replaceText: String? public var findPanelVisible: Bool? public init( - cursorPositions: [CursorPosition], + cursorPositions: [CursorPosition]? = nil, scrollPosition: CGPoint? = nil, findText: String? = nil, + replaceText: String? = nil, findPanelVisible: Bool? = nil ) { self.cursorPositions = cursorPositions self.scrollPosition = scrollPosition self.findText = findText + self.replaceText = replaceText self.findPanelVisible = findPanelVisible } } From 368c54d0f484f636cc5894ac9f6ec5c6b6713d5b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:24:18 -0500 Subject: [PATCH 21/23] lint:fix, Use Real Package Version --- Package.swift | 5 +- .../Find/ViewModel/FindPanelViewModel.swift | 9 +-- .../SourceEditor+Coordinator.swift | 36 +++++++--- .../SourceEditor/SourceEditor.swift | 67 ++++++++++--------- 4 files changed, 68 insertions(+), 49 deletions(-) diff --git a/Package.swift b/Package.swift index 5a654921a..3d8026922 100644 --- a/Package.swift +++ b/Package.swift @@ -16,9 +16,8 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( -// url: "https://github.com/CodeEditApp/CodeEditTextView.git", -// from: "0.11.3" - path: "../CodeEditTextView" + url: "https://github.com/CodeEditApp/CodeEditTextView.git", + from: "0.11.3" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index c26fc48da..e50bc6e27 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -10,10 +10,11 @@ import Combine import CodeEditTextView class FindPanelViewModel: ObservableObject { - static let findPanelTextDidChangeNotification = Notification.Name("FindPanelViewModel.findPanelTextDidChange") - // swiftlint:disable:next line_length - static let findPanelReplaceTextDidChangeNotification = Notification.Name("FindPanelViewModel.findPanelReplaceTextDidChange") - static let findPanelDidToggleNotification = Notification.Name("FindPanelViewModel.findPanelDidToggle") + enum Notifications { + static let textDidChange = Notification.Name("FindPanelViewModel.textDidChange") + static let replaceTextDidChange = Notification.Name("FindPanelViewModel.replaceTextDidChange") + static let didToggle = Notification.Name("FindPanelViewModel.didToggle") + } weak var target: FindPanelTarget? var dismiss: (() -> Void)? diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index 2969b17a9..fe7420f95 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -34,7 +34,15 @@ extension SourceEditor { self.controller = controller // swiftlint:disable:next notification_center_detachment NotificationCenter.default.removeObserver(self) + listenToTextViewNotifications(controller: controller) + listenToCursorNotifications(controller: controller) + listenToFindNotifications(controller: controller) + } + + // MARK: - Listeners + /// Listen to anything related to the text view. + func listenToTextViewNotifications(controller: TextViewController) { NotificationCenter.default.addObserver( self, selector: #selector(textViewDidChangeText(_:)), @@ -42,13 +50,6 @@ extension SourceEditor { object: controller.textView ) - NotificationCenter.default.addObserver( - self, - selector: #selector(textControllerCursorsDidUpdate(_:)), - name: TextViewController.cursorPositionUpdatedNotification, - object: controller - ) - // Needs to be put on the main runloop or SwiftUI gets mad about updating state during view updates. NotificationCenter.default .publisher( @@ -60,10 +61,23 @@ extension SourceEditor { self?.textControllerScrollDidChange(notification) } .store(in: &cancellables) + } + /// Listen to the cursor publisher on the text view controller. + func listenToCursorNotifications(controller: TextViewController) { + NotificationCenter.default.addObserver( + self, + selector: #selector(textControllerCursorsDidUpdate(_:)), + name: TextViewController.cursorPositionUpdatedNotification, + object: controller + ) + } + + /// Listen to all find panel notifications. + func listenToFindNotifications(controller: TextViewController) { NotificationCenter.default .publisher( - for: FindPanelViewModel.findPanelTextDidChangeNotification, + for: FindPanelViewModel.Notifications.textDidChange, object: controller ) .receive(on: RunLoop.main) @@ -74,7 +88,7 @@ extension SourceEditor { NotificationCenter.default .publisher( - for: FindPanelViewModel.findPanelReplaceTextDidChangeNotification, + for: FindPanelViewModel.Notifications.replaceTextDidChange, object: controller ) .receive(on: RunLoop.main) @@ -85,7 +99,7 @@ extension SourceEditor { NotificationCenter.default .publisher( - for: FindPanelViewModel.findPanelDidToggleNotification, + for: FindPanelViewModel.Notifications.didToggle, object: controller ) .receive(on: RunLoop.main) @@ -95,6 +109,8 @@ extension SourceEditor { .store(in: &cancellables) } + // MARK: - Update Published State + func updateHighlightProviders(_ highlightProviders: [any HighlightProviding]?) { guard let highlightProviders else { return // Keep our default `TreeSitterClient` if they're `nil` diff --git a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift index 89d098b6b..463bcbe4d 100644 --- a/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift +++ b/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift @@ -124,38 +124,7 @@ public struct SourceEditor: NSViewControllerRepresentable { context.coordinator.isUpdateFromTextView = false } else { context.coordinator.isUpdatingFromRepresentable = true - if let cursorPositions = state.cursorPositions, cursorPositions != state.cursorPositions { - controller.setCursorPositions(cursorPositions) - } - - if let scrollPosition = state.scrollPosition, scrollPosition != state.scrollPosition { - controller.scrollView.scroll(controller.scrollView.contentView, to: scrollPosition) - controller.scrollView.reflectScrolledClipView(controller.scrollView.contentView) - controller.gutterView.needsDisplay = true - NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: controller.textView) - } - - if let findText = state.findText, findText != controller.findViewController?.viewModel.findText { - controller.findViewController?.viewModel.findText = findText - } - - if let replaceText = state.replaceText, replaceText != controller.findViewController?.viewModel.replaceText { - controller.findViewController?.viewModel.replaceText = replaceText - } - - if let findPanelVisible = state.findPanelVisible, - let findController = controller.findViewController, - findController.viewModel.isShowingFindPanel != findPanelVisible { - // Needs to be on the next runloop, not many great ways to do this besides a dispatch... - DispatchQueue.main.async { - if findPanelVisible { - findController.showFindPanel() - } else { - findController.hideFindPanel() - } - } - } - + updateControllerWithState(state, controller: controller) context.coordinator.isUpdatingFromRepresentable = false } @@ -178,6 +147,40 @@ public struct SourceEditor: NSViewControllerRepresentable { return } + private func updateControllerWithState(_ state: SourceEditorState, controller: TextViewController) { + if let cursorPositions = state.cursorPositions, cursorPositions != state.cursorPositions { + controller.setCursorPositions(cursorPositions) + } + + if let scrollPosition = state.scrollPosition, scrollPosition != state.scrollPosition { + controller.scrollView.scroll(controller.scrollView.contentView, to: scrollPosition) + controller.scrollView.reflectScrolledClipView(controller.scrollView.contentView) + controller.gutterView.needsDisplay = true + NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: controller.textView) + } + + if let findText = state.findText, findText != controller.findViewController?.viewModel.findText { + controller.findViewController?.viewModel.findText = findText + } + + if let replaceText = state.replaceText, replaceText != controller.findViewController?.viewModel.replaceText { + controller.findViewController?.viewModel.replaceText = replaceText + } + + if let findPanelVisible = state.findPanelVisible, + let findController = controller.findViewController, + findController.viewModel.isShowingFindPanel != findPanelVisible { + // Needs to be on the next runloop, not many great ways to do this besides a dispatch... + DispatchQueue.main.async { + if findPanelVisible { + findController.showFindPanel() + } else { + findController.hideFindPanel() + } + } + } + } + private func updateHighlighting(_ controller: TextViewController, coordinator: Coordinator) { if !areHighlightProvidersEqual(controller: controller, coordinator: coordinator) { controller.setHighlightProviders(coordinator.highlightProviders) From 0608ddab1542f980994bdef444be79ae444e2af8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:40:02 -0500 Subject: [PATCH 22/23] Update Documentation and README Examples --- README.md | 32 +++++++++++++++-- .../Documentation.docc/SourceEditor.md | 34 ++++++++++++++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 67d1c025c..25f83d8a2 100644 --- a/README.md +++ b/README.md @@ -38,20 +38,28 @@ This package is fully documented [here](https://codeeditapp.github.io/CodeEditSo ## Usage (SwiftUI) +CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. The SwiftUI API provides extremely customizable and flexible configuration options, including two-way bindings for state like cursor positions and scroll position. + +For more complex features that require access to the underlying text view or text storage, we've developed the API. Using this API, developers can inject custom behavior into the editor as events happen, without having to work with state or bindings. + ```swift import CodeEditSourceEditor struct ContentView: View { @State var text = "let x = 1.0" - /// Automatically updates with cursor positions, or update the binding to set the user's cursors. - @State var cursorPositions: [CursorPosition] = [] + /// Automatically updates with cursor positions, scroll position, find panel text. + /// Everything in this object is two-way, use it to update cursor positions, scroll position, etc. + @State var editorState = SourceEditorState() /// Configure the editor's appearance, features, and editing behavior... @State var theme = EditorTheme(...) @State var font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) @State var indentOption = .spaces(count: 4) + /// *Powerful* customization options with our text view coordinators API + @State var autoCompleteCoordinator = AutoCompleteCoordinator() + var body: some View { SourceEditor( $text, @@ -61,9 +69,27 @@ struct ContentView: View { appearance: .init(theme: theme, font: font), behavior: .init(indentOption: indentOption) ), - cursorPositions: $cursorPositions + state: $editorState, + coordinators: [autoCompleteCoordinator] ) } + + /// Autocompletes "Hello" to "Hello world!" whenever it's typed. + final class AutoCompleteCoordinator: TextViewCoordinator { + func prepareCoordinator(controller: TextViewController) { } + + func textViewDidChangeText(controller: TextViewController) { + for cursorPosition in controller.cursorPositions where cursorPosition.range.location >= 5 { + let location = cursorPosition.range.location + let previousRange = NSRange(start: location - 5, end: location) + let string = (controller.text as NSString).substring(with: previousRange) + + if string.lowercased() == "hello" { + controller.textView.replaceCharacters(in: NSRange(location: location, length: 0), with: " world!") + } + } + } + } } ``` diff --git a/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md b/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md index 4a977024b..b3cec1725 100644 --- a/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md +++ b/Sources/CodeEditSourceEditor/Documentation.docc/SourceEditor.md @@ -2,7 +2,9 @@ ## Usage -CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. +CodeEditSourceEditor provides two APIs for creating an editor: SwiftUI and AppKit. We provide a fast and efficient SwiftUI API that avoids unnecessary view updates whenever possible. It also provides extremely customizable and flexible configuration options, including two-way bindings for state like cursor positions and scroll position. + +For more complex features that require access to the underlying text view or text storage, we've developed the API. Using this API, developers can inject custom behavior into the editor as events happen, without having to work with state or bindings. #### SwiftUI @@ -12,11 +14,12 @@ import CodeEditSourceEditor struct ContentView: View { @State var text = "let x = 1.0" - // For large documents use (avoids SwiftUI inneficiency) + // For large documents use a text storage object (avoids SwiftUI comparisons) // var text: NSTextStorage - /// Automatically updates with cursor positions, or update the binding to set the user's cursors. - @State var cursorPositions: [CursorPosition] = [] + /// Automatically updates with cursor positions, scroll position, find panel text. + /// Everything in this object is two-way, use it to update cursor positions, scroll position, etc. + @State var editorState = SourceEditorState() /// Configure the editor's appearance, features, and editing behavior... @State var theme = EditorTheme(...) @@ -25,6 +28,9 @@ struct ContentView: View { @State var editorOverscroll = 0.3 @State var showMinimap = true + /// *Powerful* customization options with text coordinators + @State var autoCompleteCoordinator = AutoCompleteCoordinator() + var body: some View { SourceEditor( $text, @@ -35,9 +41,27 @@ struct ContentView: View { layout: .init(editorOverscroll: editorOverscroll), peripherals: .init(showMinimap: showMinimap) ), - cursorPositions: $cursorPositions + state: $editorState, + coordinators: [autoCompleteCoordinator] ) } + + /// Autocompletes "Hello" to "Hello world!" whenever it's typed. + class AutoCompleteCoordinator: TextViewCoordinator { + func prepareCoordinator(controller: TextViewController) { } + + func textViewDidChangeText(controller: TextViewController) { + for cursorPosition in controller.cursorPositions.reversed() where cursorPosition.range.location >= 5 { + let location = cursorPosition.range.location + let previousRange = NSRange(start: location - 5, end: location) + let string = (controller.text as NSString).substring(with: previousRange) + + if string.lowercased() == "hello" { + controller.textView.replaceCharacters(in: NSRange(location: location, length: 0), with: " world!") + } + } + } + } } ``` From ec5d847bb9e4a1a9db2296657c63c4eac125fd7f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:41:10 -0500 Subject: [PATCH 23/23] Finish Notification Rename --- .../CodeEditSourceEditor/Find/FindViewController+Toggle.swift | 4 ++-- .../Find/ViewModel/FindPanelViewModel.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 84bc16164..d607af3eb 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -46,7 +46,7 @@ extension FindViewController { findPanel.addEventMonitor() NotificationCenter.default.post( - name: FindPanelViewModel.findPanelDidToggleNotification, + name: FindPanelViewModel.Notifications.didToggle, object: viewModel.target ) } @@ -77,7 +77,7 @@ extension FindViewController { } NotificationCenter.default.post( - name: FindPanelViewModel.findPanelDidToggleNotification, + name: FindPanelViewModel.Notifications.didToggle, object: viewModel.target ) } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index e50bc6e27..e079783d4 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -106,10 +106,10 @@ class FindPanelViewModel: ObservableObject { target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) find() - NotificationCenter.default.post(name: Self.findPanelTextDidChangeNotification, object: target) + NotificationCenter.default.post(name: Self.Notifications.textDidChange, object: target) } func replaceTextDidChange() { - NotificationCenter.default.post(name: Self.findPanelReplaceTextDidChangeNotification, object: target) + NotificationCenter.default.post(name: Self.Notifications.replaceTextDidChange, object: target) } }