From 61e5f5a95bbacc96c733f07b31b9856c628aca30 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 29 May 2025 11:22:57 -0500 Subject: [PATCH 01/14] Add Ribbon View, Demo Fold Provider, Ribbon Toggles (#315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description > [!NOTE] > For reviewers, this is merging into the dev branch. These changes require the version of CETV in [this PR](https://github.com/CodeEditApp/CodeEditTextView/pull/93). Please pull those changes locally and test using that. > [!NOTE] > I'll be making some TODOs in the tracking issue #43 for things that aren't included here. Like the overlapping folds UI issue. Adds the first version of the code folding ribbon, with a very basic folding model. This is mostly a UI change. It includes changes to the gutter, and a new view for displaying folds. The model and related demo fold provider should be considered incomplete and only for demo purposes. This also doesn't implement the hover state yet. Just a very basic outline of everything. Things to review: - New `FoldingRibbonView` - New `LineFoldingModel` - Changes in `GutterView` - Changes to `TextViewController` - Changes to `CodeEditSourceEditor` ### Related Issues * #43 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Light mode. ![Screenshot 2025-05-07 at 3 22 17 PM](https://github.com/user-attachments/assets/a9b7838f-6bea-4bb4-bd61-b72175c76788) Dark Mode. ![Screenshot 2025-05-08 at 10 12 45 AM](https://github.com/user-attachments/assets/fb8e3264-71ec-40aa-9d62-7d4a74c15343) Folds are transparent for scrolling text. ![Screenshot 2025-05-08 at 10 08 35 AM](https://github.com/user-attachments/assets/17a1623c-3e8e-40a5-ace3-6adbe8e13320) --------- Co-authored-by: Austin Condiff --- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Views/ContentView.swift | 7 +- .../Views/StatusBar.swift | 2 + Package.resolved | 9 + Package.swift | 10 +- .../CodeEditSourceEditor.swift | 28 ++- .../TextViewController+Cursor.swift | 6 +- .../TextViewController+FindPanelTarget.swift | 10 +- ...extViewController+GutterViewDelegate.swift | 5 +- ...ift => TextViewController+Lifecycle.swift} | 14 +- .../TextViewController+StyleViews.swift | 7 +- .../Controller/TextViewController.swift | 28 +-- .../TextView+/TextView+TextFormation.swift | 1 + .../Find/FindPanelDelegate.swift | 18 -- .../Find/FindPanelMode.swift | 20 ++ .../Find/FindPanelTarget.swift | 9 +- ...FindViewController+FindPanelDelegate.swift | 191 ---------------- .../Find/FindViewController+Operations.swift | 125 ----------- .../Find/FindViewController+Toggle.swift | 34 +-- .../Find/FindViewController.swift | 40 +--- .../Find/PanelView/FindControls.swift | 117 ++++++++++ .../Find/PanelView/FindModePicker.swift | 177 +++++++++++++++ .../Find/PanelView/FindPanel.swift | 122 ---------- .../Find/PanelView/FindPanelContent.swift | 54 +++++ .../Find/PanelView/FindPanelHostingView.swift | 71 ++++++ .../Find/PanelView/FindPanelView.swift | 177 ++++++++++----- .../Find/PanelView/FindPanelViewModel.swift | 63 ------ .../Find/PanelView/FindSearchField.swift | 156 +++++++++++++ .../Find/PanelView/ReplaceControls.swift | 117 ++++++++++ .../Find/PanelView/ReplaceSearchField.swift | 99 +++++++++ .../FindPanelViewModel+Emphasis.swift | 64 ++++++ .../ViewModel/FindPanelViewModel+Find.swift | 88 ++++++++ .../ViewModel/FindPanelViewModel+Move.swift | 70 ++++++ .../FindPanelViewModel+Replace.swift | 88 ++++++++ .../Find/ViewModel/FindPanelViewModel.swift | 96 ++++++++ .../Gutter/GutterView.swift | 111 ++++++--- .../IndentationLineFoldProvider.swift | 34 +++ .../Gutter/LineFolding/FoldRange.swift | 25 +++ .../LineFolding/FoldingRibbonView.swift | 210 ++++++++++++++++++ .../Gutter/LineFolding/LineFoldProvider.swift | 13 ++ .../Gutter/LineFolding/LineFoldingModel.swift | 155 +++++++++++++ .../Minimap/MinimapLineRenderer.swift | 12 +- .../MinimapView+DocumentVisibleView.swift | 2 +- .../ReformattingGuideView.swift | 11 + .../SupportingViews/PanelTextField.swift | 29 ++- .../TextViewControllerTests.swift | 69 ++++-- .../FindPanelViewModelTests.swift | 42 ++++ .../LineFoldingModelTests.swift | 55 +++++ Tests/CodeEditSourceEditorTests/Mock.swift | 3 +- 49 files changed, 2177 insertions(+), 726 deletions(-) rename Sources/CodeEditSourceEditor/Controller/{TextViewController+LoadView.swift => TextViewController+Lifecycle.swift} (96%) delete mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelMode.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift rename Tests/CodeEditSourceEditorTests/{ => Controller}/TextViewControllerTests.swift (89%) create mode 100644 Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift create mode 100644 Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift 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 a1eb3b548..3f475425b 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "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", diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 8a42a5f1a..640476346 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -27,6 +27,7 @@ struct ContentView: View { @State private var settingsIsPresented: Bool = false @State private var treeSitterClient = TreeSitterClient() @AppStorage("showMinimap") private var showMinimap: Bool = true + @AppStorage("showFoldingRibbon") private var showFoldingRibbon: Bool = true @State private var indentOption: IndentOption = .spaces(count: 4) @AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80 @AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false @@ -56,7 +57,8 @@ struct ContentView: View { useSystemCursor: useSystemCursor, showMinimap: showMinimap, reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + showFoldingRibbon: showFoldingRibbon ) .overlay(alignment: .bottom) { StatusBar( @@ -71,7 +73,8 @@ struct ContentView: View { showMinimap: $showMinimap, indentOption: $indentOption, reformatAtColumn: $reformatAtColumn, - showReformattingGuide: $showReformattingGuide + showReformattingGuide: $showReformattingGuide, + showFoldingRibbon: $showFoldingRibbon ) } .ignoresSafeArea() diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 779f5cd35..78721fbb8 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -26,6 +26,7 @@ struct StatusBar: View { @Binding var indentOption: IndentOption @Binding var reformatAtColumn: Int @Binding var showReformattingGuide: Bool + @Binding var showFoldingRibbon: Bool var body: some View { HStack { @@ -43,6 +44,7 @@ struct StatusBar: View { .onChange(of: reformatAtColumn) { _, newValue in reformatAtColumn = max(1, min(200, newValue)) } + Toggle("Show Folding Ribbon", isOn: $showFoldingRibbon) if #available(macOS 14, *) { Toggle("Use System Cursor", isOn: $useSystemCursor) } else { diff --git a/Package.resolved b/Package.resolved index b646b2e64..1d320e4a6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "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", diff --git a/Package.swift b/Package.swift index a14085dcf..751fc8829 100644 --- a/Package.swift +++ b/Package.swift @@ -17,13 +17,18 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.10.1" + from: "0.11.0" ), // tree-sitter languages .package( url: "https://github.com/CodeEditApp/CodeEditLanguages.git", exact: "0.1.20" ), + // CodeEditSymbols + .package( + url: "https://github.com/CodeEditApp/CodeEditSymbols.git", + exact: "0.2.3" + ), // SwiftLint .package( url: "https://github.com/lukepistrol/SwiftLintPlugin", @@ -43,7 +48,8 @@ let package = Package( dependencies: [ "CodeEditTextView", "CodeEditLanguages", - "TextFormation" + "TextFormation", + "CodeEditSymbols" ], plugins: [ .plugin(name: "SwiftLint", package: "SwiftLintPlugin") diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 12a0f5a10..3e98891a7 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -39,7 +39,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// 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. + /// - additionalTextInsets: A set of extra text insets to indent only *text* content. Does not effect + /// decorations like the find panel. /// - 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. @@ -47,12 +48,13 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// 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`. + /// - useSystemCursor: Use the system cursor instead of the default line cursor. Only available after 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 + /// - showMinimap: Toggle the visibility of the minimap. /// - reformatAtColumn: The column to reformat at /// - showReformattingGuide: Whether to show the reformatting guide + /// - showFoldingRibbon: Toggle the visibility of the line folding ribbon. public init( _ text: Binding, language: CodeLanguage, @@ -77,7 +79,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + showFoldingRibbon: Bool ) { self.text = .binding(text) self.language = language @@ -107,6 +110,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon } /// Initializes a Text Editor @@ -127,6 +131,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// 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: A set of extra text insets to indent only *text* content. Does not effect + /// decorations like the find panel. /// - 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. @@ -134,11 +140,13 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// 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` + /// - useSystemCursor: Use the system cursor instead of the default line cursor. Only available after 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 + /// - showMinimap: Toggle the visibility of the minimap. /// - reformatAtColumn: The column to reformat at /// - showReformattingGuide: Whether to show the reformatting guide + /// - showFoldingRibbon: Toggle the visibility of the line folding ribbon. public init( _ text: NSTextStorage, language: CodeLanguage, @@ -163,7 +171,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: [any TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int, - showReformattingGuide: Bool + showReformattingGuide: Bool, + showFoldingRibbon: Bool ) { self.text = .storage(text) self.language = language @@ -193,6 +202,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon } package var text: TextAPI @@ -219,6 +229,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { package var showMinimap: Bool private var reformatAtColumn: Int private var showReformattingGuide: Bool + package var showFoldingRibbon: Bool public typealias NSViewControllerType = TextViewController @@ -247,7 +258,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { coordinators: coordinators, showMinimap: showMinimap, reformatAtColumn: reformatAtColumn, - showReformattingGuide: showReformattingGuide + showReformattingGuide: showReformattingGuide, + showFoldingRibbon: showFoldingRibbon ) switch text { case .binding(let binding): @@ -336,6 +348,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.contentInsets = contentInsets controller.additionalTextInsets = additionalTextInsets controller.showMinimap = showMinimap + controller.showFoldingRibbon = showFoldingRibbon if controller.indentOption != indentOption { controller.indentOption = indentOption @@ -397,6 +410,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.showMinimap == showMinimap && controller.reformatAtColumn == reformatAtColumn && controller.showReformattingGuide == showReformattingGuide && + controller.showFoldingRibbon == showFoldingRibbon && areHighlightProvidersEqual(controller: controller, coordinator: coordinator) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index de2783f76..04af69ac7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -11,7 +11,7 @@ import AppKit extension TextViewController { /// Sets new cursor positions. /// - Parameter positions: The positions to set. Lines and columns are 1-indexed. - public func setCursorPositions(_ positions: [CursorPosition]) { + public func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool = false) { if isPostingCursorNotification { return } var newSelectedRanges: [NSRange] = [] for position in positions { @@ -33,6 +33,10 @@ extension TextViewController { } } textView.selectionManager.setSelectedRanges(newSelectedRanges) + + if scrollToVisible { + textView.scrollSelectionToVisible() + } } /// Update the ``TextViewController/cursorPositions`` variable with new text selections from the text view. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 697ccc54b..3401ea3cf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -5,10 +5,14 @@ // Created by Khan Winter on 3/16/25. // -import Foundation +import AppKit import CodeEditTextView extension TextViewController: FindPanelTarget { + var findPanelTargetView: NSView { + textView + } + func findPanelWillShow(panelHeight: CGFloat) { updateContentInsets() } @@ -17,6 +21,10 @@ extension TextViewController: FindPanelTarget { updateContentInsets() } + func findPanelModeDidChange(to mode: FindPanelMode) { + updateContentInsets() + } + var emphasisManager: EmphasisManager? { textView?.emphasisManager } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift index 20abe130c..d0abfaaa8 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift @@ -8,8 +8,7 @@ import Foundation extension TextViewController: GutterViewDelegate { - public func gutterViewWidthDidUpdate(newWidth: CGFloat) { - gutterView?.frame.size.width = newWidth - textView?.textInsets = textViewInsets + public func gutterViewWidthDidUpdate() { + updateTextInsets() } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift similarity index 96% rename from Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift rename to Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 1b960ed48..efe1f905e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -9,6 +9,13 @@ import CodeEditTextView import AppKit extension TextViewController { + override public func viewWillAppear() { + super.viewWillAppear() + // The calculation this causes cannot be done until the view knows it's final position + updateTextInsets() + minimapView.layout() + } + override public func loadView() { super.loadView() @@ -106,9 +113,7 @@ extension TextViewController { object: scrollView.contentView, queue: .main ) { [weak self] notification in - guard let clipView = notification.object as? NSClipView, - let textView = self?.textView else { return } - textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + guard let clipView = notification.object as? NSClipView else { return } self?.gutterView.needsDisplay = true self?.minimapXConstraint?.constant = clipView.bounds.origin.x } @@ -120,7 +125,6 @@ extension TextViewController { object: scrollView.contentView, queue: .main ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) self?.gutterView.needsDisplay = true self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) self?.updateTextInsets() @@ -220,7 +224,7 @@ extension TextViewController { self.findViewController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.findViewController?.findPanel.dismiss() + self.findViewController?.hideFindPanel() return nil case (_, _): return event diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 2cc2f13b5..65af63cb3 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -56,6 +56,7 @@ extension TextViewController { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear } + gutterView.showFoldingRibbon = showFoldingRibbon } /// Style the scroll view. @@ -96,7 +97,11 @@ extension TextViewController { minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 // Inset the top by the find panel height - let findInset = (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 + let findInset: CGFloat = if findViewController?.viewModel.isShowingFindPanel ?? false { + findViewController?.viewModel.panelHeight ?? 0 + } else { + 0 + } scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 8d3b8b69f..758e5dc74 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -204,6 +204,13 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } + /// Toggles the line folding ribbon in the gutter view. + public var showFoldingRibbon: Bool { + didSet { + gutterView?.showFoldingRibbon = showFoldingRibbon + } + } + var textCoordinators: [WeakCoordinator] = [] var highlighter: Highlighter? @@ -229,7 +236,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty package var textViewInsets: HorizontalEdgeInsets { HorizontalEdgeInsets( - left: gutterView.gutterWidth, + left: gutterView?.frame.width ?? 0.0, right: textViewTrailingInset ) } @@ -265,6 +272,9 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } } + /// A default `NSParagraphStyle` with a set `lineHeight` + package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + // MARK: Init init( @@ -291,7 +301,8 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty coordinators: [TextViewCoordinator] = [], showMinimap: Bool, reformatAtColumn: Int = 80, - showReformattingGuide: Bool = false + showReformattingGuide: Bool = false, + showFoldingRibbon: Bool ) { self.language = language self.font = font @@ -314,6 +325,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.showMinimap = showMinimap self.reformatAtColumn = reformatAtColumn self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon super.init(nibName: nil, bundle: nil) @@ -362,18 +374,6 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty self.gutterView.setNeedsDisplay(self.gutterView.frame) } - // MARK: Paragraph Style - - /// A default `NSParagraphStyle` with a set `lineHeight` - package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() - - override public func viewWillAppear() { - super.viewWillAppear() - // The calculation this causes cannot be done until the view knows it's final position - updateTextInsets() - minimapView.layout() - } - deinit { if let highlighter { textView.removeStorageDelegate(highlighter) diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 346410874..853773412 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -45,5 +45,6 @@ extension TextView: TextInterface { in: mutation.range, replacementLength: (mutation.string as NSString).length ) + layoutManager.setNeedsLayout() } } diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift deleted file mode 100644 index 2fb440929..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// FindPanelDelegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import Foundation - -protocol FindPanelDelegate: AnyObject { - func findPanelOnSubmit() - func findPanelOnDismiss() - func findPanelDidUpdate(_ searchText: String) - func findPanelPrevButtonClicked() - func findPanelNextButtonClicked() - func findPanelUpdateMatchCount(_ count: Int) - func findPanelClearEmphasis() -} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift new file mode 100644 index 000000000..f7bbf26bd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift @@ -0,0 +1,20 @@ +// +// FindPanelMode.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +enum FindPanelMode: CaseIterable { + case find + case replace + + var displayName: String { + switch self { + case .find: + return "Find" + case .replace: + return "Replace" + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index af0facadd..640b00166 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -5,17 +5,18 @@ // Created by Khan Winter on 3/10/25. // -import Foundation +import AppKit import CodeEditTextView protocol FindPanelTarget: AnyObject { - var emphasisManager: EmphasisManager? { get } - var text: String { get } + var textView: TextView! { get } + var findPanelTargetView: NSView { get } var cursorPositions: [CursorPosition] { get } - func setCursorPositions(_ positions: [CursorPosition]) + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) func updateCursorPosition() func findPanelWillShow(panelHeight: CGFloat) func findPanelWillHide(panelHeight: CGFloat) + func findPanelModeDidChange(to mode: FindPanelMode) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift deleted file mode 100644 index 7b0ded2a2..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// FindViewController+Delegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController: FindPanelDelegate { - func findPanelOnSubmit() { - findPanelNextButtonClicked() - } - - func findPanelOnDismiss() { - if isShowingFindPanel { - hideFindPanel() - // Ensure text view becomes first responder after hiding - if let textViewController = target as? TextViewController { - DispatchQueue.main.async { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) - } - } - } - } - - func findPanelDidUpdate(_ text: String) { - // Check if this update was triggered by a return key without shift - if let currentEvent = NSApp.currentEvent, - currentEvent.type == .keyDown, - currentEvent.keyCode == 36, // Return key - !currentEvent.modifierFlags.contains(.shift) { - return // Skip find for regular return key - } - - // Only perform find if we're focusing the text view - if let textViewController = target as? TextViewController, - textViewController.textView.window?.firstResponder === textViewController.textView { - // If the text view has focus, just clear visual emphases but keep matches in memory - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - // Re-add the current active emphasis without visual emphasis - if let emphases = target?.emphasisManager?.getEmphases(for: EmphasisGroup.find), - let activeEmphasis = emphases.first(where: { !$0.inactive }) { - target?.emphasisManager?.addEmphasis( - Emphasis( - range: activeEmphasis.range, - style: .standard, - flash: false, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - } - return - } - - // Clear existing emphases before performing new find - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - find(text: text) - } - - func findPanelPrevButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - return - } - - // Update to previous match - let oldIndex = currentFindMatchIndex - currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count - - // Show bezel notification if we cycled from first to last match - if oldIndex == 0 && currentFindMatchIndex == findMatches.count - 1 { - BezelNotification.show( - symbolName: "arrow.trianglehead.bottomleft.capsulepath.clockwise", - over: textViewController.textView - ) - } - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - let newActiveRange = findMatches[currentFindMatchIndex] - - // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - - return - } - - // Create updated emphases with new active state - let updatedEmphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) - } - - func findPanelNextButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - // Show "no matches" bezel notification and play beep - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - return - } - - // Update to next match - let oldIndex = currentFindMatchIndex - currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count - - // Show bezel notification if we cycled from last to first match - if oldIndex == findMatches.count - 1 && currentFindMatchIndex == 0 { - BezelNotification.show( - symbolName: "arrow.triangle.capsulepath", - over: textViewController.textView - ) - } - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - let newActiveRange = findMatches[currentFindMatchIndex] - - // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - - return - } - - // Create updated emphases with new active state - let updatedEmphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) - } - - func findPanelUpdateMatchCount(_ count: Int) { - findPanel.updateMatchCount(count) - } - - func findPanelClearEmphasis() { - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift deleted file mode 100644 index d67054f39..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// FindViewController+Operations.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController { - func find(text: String) { - findText = text - performFind() - addEmphases() - } - - func performFind() { - // Don't find if target or emphasisManager isn't ready - guard let target = target else { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - // Clear emphases and return if query is empty - if findText.isEmpty { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - let findOptions: NSRegularExpression.Options = smartCase(str: findText) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: findText) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - let text = target.text - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - - findMatches = matches.map { $0.range } - findPanel.findDelegate?.findPanelUpdateMatchCount(findMatches.count) - - // Find the nearest match to the current cursor position - currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 - } - - private func addEmphases() { - guard let target = target, - let emphasisManager = target.emphasisManager else { return } - - // Clear existing emphases - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - // Create emphasis with the nearest match as active - let emphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Add all emphases - emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) - } - - private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { - // order the array as follows - // Found: 1 -> 2 -> 3 -> 4 - // Cursor: | - // Result: 3 -> 4 -> 1 -> 2 - guard let cursorPosition = target?.cursorPositions.first else { return nil } - let start = cursorPosition.range.location - - var left = 0 - var right = matchRanges.count - 1 - var bestIndex = -1 - var bestDiff = Int.max // Stores the closest difference - - while left <= right { - let mid = left + (right - left) / 2 - let midStart = matchRanges[mid].location - let diff = abs(midStart - start) - - // If it's an exact match, return immediately - if diff == 0 { - return mid - } - - // If this is the closest so far, update the best index - if diff < bestDiff { - bestDiff = diff - bestIndex = mid - } - - // Move left or right based on the cursor position - if midStart < start { - left = mid + 1 - } else { - right = mid - 1 - } - } - - return bestIndex >= 0 ? bestIndex : nil - } - - // Only re-find the part of the file that changed upwards - private func reFind() { } - - // Returns true if string contains uppercase letter - // used for: ignores letter case if the find text is all lowercase - private func smartCase(str: String) -> Bool { - return str.range(of: "[A-Z]", options: .regularExpression) != nil - } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 99645ce08..bfea53c92 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -16,29 +16,34 @@ extension FindViewController { /// - Animates the find panel into position (resolvedTopPadding). /// - Makes the find panel the first responder. func showFindPanel(animated: Bool = true) { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { // If panel is already showing, just focus the text field - _ = findPanel?.becomeFirstResponder() + viewModel.isFocused = true return } - isShowingFindPanel = true + if viewModel.mode == .replace { + viewModel.mode = .find + } + + viewModel.isShowingFindPanel = true // Smooth out the animation by placing the find panel just outside the correct position before animating. findPanel.isHidden = false - findPanelVerticalConstraint.constant = resolvedTopPadding - FindPanel.height + findPanelVerticalConstraint.constant = resolvedTopPadding - viewModel.panelHeight + view.layoutSubtreeIfNeeded() // Perform the animation conditionalAnimated(animated) { // SwiftUI breaks things here, and refuses to return the correct `findPanel.fittingSize` so we // are forced to use a constant number. - target?.findPanelWillShow(panelHeight: FindPanel.height) + viewModel.target?.findPanelWillShow(panelHeight: viewModel.panelHeight) setFindPanelConstraintShow() } onComplete: { } - _ = findPanel?.becomeFirstResponder() - findPanel?.addEventMonitor() + viewModel.isFocused = true + findPanel.addEventMonitor() } /// Hide the find panel @@ -49,20 +54,21 @@ extension FindViewController { /// - Hides the find panel. /// - Sets the text view to be the first responder. func hideFindPanel(animated: Bool = true) { - isShowingFindPanel = false - _ = findPanel?.resignFirstResponder() - findPanel?.removeEventMonitor() + viewModel.isShowingFindPanel = false + _ = findPanel.resignFirstResponder() + findPanel.removeEventMonitor() conditionalAnimated(animated) { - target?.findPanelWillHide(panelHeight: FindPanel.height) + viewModel.target?.findPanelWillHide(panelHeight: viewModel.panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in self?.findPanel.isHidden = true + self?.viewModel.isFocused = false } // Set first responder back to text view - if let textViewController = target as? TextViewController { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) + if let target = viewModel.target { + _ = target.findPanelTargetView.window?.makeFirstResponder(target.findPanelTargetView) } } @@ -113,7 +119,7 @@ extension FindViewController { // SwiftUI hates us. It refuses to move views outside of the safe are if they don't have the `.ignoresSafeArea` // modifier, but with that modifier on it refuses to allow it to be animated outside the safe area. // The only way I found to fix it was to multiply the height by 3 here. - findPanelVerticalConstraint.constant = resolvedTopPadding - (FindPanel.height * 3) + findPanelVerticalConstraint.constant = resolvedTopPadding - (viewModel.panelHeight * 3) findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 4d9172c92..a9e2dd3b0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -10,28 +10,22 @@ import CodeEditTextView /// Creates a container controller for displaying and hiding a find panel with a content view. final class FindViewController: NSViewController { - weak var target: FindPanelTarget? + var viewModel: FindPanelViewModel /// The amount of padding from the top of the view to inset the find panel by. /// When set, the safe area is ignored, and the top padding is measured from the top of the view's frame. var topPadding: CGFloat? { didSet { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { setFindPanelConstraintShow() } } } var childView: NSView - var findPanel: FindPanel! - var findMatches: [NSRange] = [] - - var currentFindMatchIndex: Int = 0 - var findText: String = "" + var findPanel: FindPanelHostingView var findPanelVerticalConstraint: NSLayoutConstraint! - var isShowingFindPanel: Bool = false - /// The 'real' top padding amount. /// Is equal to ``topPadding`` if set, or the view's top safe area inset if not. var resolvedTopPadding: CGFloat { @@ -39,30 +33,12 @@ final class FindViewController: NSViewController { } init(target: FindPanelTarget, childView: NSView) { - self.target = target + viewModel = FindPanelViewModel(target: target) self.childView = childView + findPanel = FindPanelHostingView(viewModel: viewModel) super.init(nibName: nil, bundle: nil) - self.findPanel = FindPanel(delegate: self, textView: target as? NSView) - - // Add notification observer for text changes - if let textViewController = target as? TextViewController { - NotificationCenter.default.addObserver( - self, - selector: #selector(textDidChange), - name: TextView.textDidChangeNotification, - object: textViewController.textView - ) - } - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc private func textDidChange() { - // Only update if we have find text - if !findText.isEmpty { - performFind() + viewModel.dismiss = { [weak self] in + self?.hideFindPanel() } } @@ -105,7 +81,7 @@ final class FindViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() - if isShowingFindPanel { // Update constraints for initial state + if viewModel.isShowingFindPanel { // Update constraints for initial state findPanel.isHidden = false setFindPanelConstraintShow() } else { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift new file mode 100644 index 000000000..ede8476da --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindControls.swift @@ -0,0 +1,117 @@ +// +// FindControls.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/30/25. +// + +import SwiftUI + +/// A SwiftUI view that provides the navigation controls for the find panel. +/// +/// The `FindControls` view is responsible for: +/// - Displaying previous/next match navigation buttons +/// - Showing a done button to dismiss the find panel +/// - Adapting button appearance based on match count +/// - Supporting both condensed and full layouts +/// - Providing tooltips for button actions +/// +/// The view is part of the find panel's control section and works in conjunction with +/// the find text field to provide navigation through search results. +struct FindControls: View { + @ObservedObject var viewModel: FindPanelViewModel + var condensed: Bool + + var imageOpacity: CGFloat { + viewModel.matchesEmpty ? 0.33 : 1 + } + + var dynamicPadding: CGFloat { + condensed ? 0 : 5 + } + + var body: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.moveToPreviousMatch() + } label: { + Image(systemName: "chevron.left") + .opacity(imageOpacity) + .padding(.horizontal, dynamicPadding) + } + .help("Previous Match") + .disabled(viewModel.matchesEmpty) + + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button { + viewModel.moveToNextMatch() + } label: { + Image(systemName: "chevron.right") + .opacity(imageOpacity) + .padding(.horizontal, dynamicPadding) + } + .help("Next Match") + .disabled(viewModel.matchesEmpty) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + + Button { + viewModel.dismiss?() + } label: { + Group { + if condensed { + Image(systemName: "xmark") + } else { + Text("Done") + } + } + .help(condensed ? "Done" : "") + .padding(.horizontal, dynamicPadding) + } + .buttonStyle(PanelButtonStyle()) + } + } +} + +#Preview("Find Controls - Full") { + FindControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: false + ) + .padding() +} + +#Preview("Find Controls - Condensed") { + FindControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: true + ) + .padding() +} + +#Preview("Find Controls - No Matches") { + FindControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + return vm + }(), + condensed: false + ) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift new file mode 100644 index 000000000..e7a076a13 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -0,0 +1,177 @@ +// +// FindModePicker.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/10/25. +// + +import SwiftUI + +/// A SwiftUI view that provides a mode picker for the find panel. +/// +/// The `FindModePicker` view is responsible for: +/// - Displaying a dropdown menu to switch between find and replace modes +/// - Managing the wrap around option for search +/// - Providing a visual indicator (magnifying glass icon) for the mode picker +/// - Adapting its appearance based on the control's active state +/// - Handling mode selection and wrap around toggling +/// +/// The view works in conjunction with the find panel to manage the current search mode +/// and wrap around settings. +struct FindModePicker: NSViewRepresentable { + @Binding var mode: FindPanelMode + @Binding var wrapAround: Bool + @Environment(\.controlActiveState) var activeState + + private func createSymbolButton(context: Context) -> NSButton { + let button = NSButton(frame: .zero) + button.bezelStyle = .regularSquare + button.isBordered = false + button.controlSize = .small + button.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(pointSize: 12, weight: .regular)) + button.imagePosition = .imageOnly + button.target = context.coordinator + button.action = nil + button.sendAction(on: .leftMouseDown) + button.target = context.coordinator + button.action = #selector(Coordinator.openMenu(_:)) + return button + } + + private func createPopupButton(context: Context) -> NSPopUpButton { + let popup = NSPopUpButton(frame: .zero, pullsDown: false) + popup.bezelStyle = .regularSquare + popup.isBordered = false + popup.controlSize = .small + popup.font = .systemFont(ofSize: NSFont.systemFontSize(for: .small)) + popup.autoenablesItems = false + return popup + } + + private func createMenu(context: Context) -> NSMenu { + let menu = NSMenu() + + // Add mode items + FindPanelMode.allCases.forEach { mode in + let item = NSMenuItem( + title: mode.displayName, + action: #selector(Coordinator.modeSelected(_:)), + keyEquivalent: "" + ) + item.target = context.coordinator + item.tag = mode == .find ? 0 : 1 + menu.addItem(item) + } + + // Add separator + menu.addItem(.separator()) + + // Add wrap around item + let wrapItem = NSMenuItem( + title: "Wrap Around", + action: #selector(Coordinator.toggleWrapAround(_:)), + keyEquivalent: "" + ) + wrapItem.target = context.coordinator + wrapItem.state = wrapAround ? .on : .off + menu.addItem(wrapItem) + + return menu + } + + private func setupConstraints(container: NSView, button: NSButton, popup: NSPopUpButton, totalWidth: CGFloat) { + button.translatesAutoresizingMaskIntoConstraints = false + popup.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + button.centerYAnchor.constraint(equalTo: container.centerYAnchor), + button.widthAnchor.constraint(equalToConstant: 16), + button.heightAnchor.constraint(equalToConstant: 20), + + popup.leadingAnchor.constraint(equalTo: button.trailingAnchor), + popup.trailingAnchor.constraint(equalTo: container.trailingAnchor), + popup.topAnchor.constraint(equalTo: container.topAnchor), + popup.bottomAnchor.constraint(equalTo: container.bottomAnchor), + popup.widthAnchor.constraint(equalToConstant: totalWidth) + ]) + } + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + + let button = createSymbolButton(context: context) + let popup = createPopupButton(context: context) + + // Calculate the required width + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + popup.menu = createMenu(context: context) + popup.selectItem(at: mode == .find ? 0 : 1) + + // Add subviews + container.addSubview(button) + container.addSubview(popup) + + setupConstraints(container: container, button: button, popup: popup, totalWidth: totalWidth) + + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + if let popup = nsView.subviews.last as? NSPopUpButton { + popup.selectItem(at: mode == .find ? 0 : 1) + if let wrapItem = popup.menu?.items.last { + wrapItem.state = wrapAround ? .on : .off + } + } + + if let button = nsView.subviews.first as? NSButton { + button.contentTintColor = activeState == .inactive ? .tertiaryLabelColor : .secondaryLabelColor + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(mode: $mode, wrapAround: $wrapAround) + } + + var body: some View { + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small)) + let maxWidth = FindPanelMode.allCases.map { mode in + mode.displayName.size(withAttributes: [.font: font]).width + }.max() ?? 0 + let totalWidth = maxWidth + 28 // Add padding for the chevron and spacing + + return self.frame(width: totalWidth) + } + + class Coordinator: NSObject { + @Binding var mode: FindPanelMode + @Binding var wrapAround: Bool + + init(mode: Binding, wrapAround: Binding) { + self._mode = mode + self._wrapAround = wrapAround + } + + @objc func openMenu(_ sender: NSButton) { + if let popup = sender.superview?.subviews.last as? NSPopUpButton { + popup.performClick(nil) + } + } + + @objc func modeSelected(_ sender: NSMenuItem) { + mode = sender.tag == 0 ? .find : .replace + } + + @objc func toggleWrapAround(_ sender: NSMenuItem) { + wrapAround.toggle() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift deleted file mode 100644 index 86506018e..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// FindPanel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import SwiftUI -import AppKit -import Combine - -// NSView wrapper for using SwiftUI view in AppKit -final class FindPanel: NSView { - static let height: CGFloat = 28 - - weak var findDelegate: FindPanelDelegate? - private var hostingView: NSHostingView! - private var viewModel: FindPanelViewModel! - private weak var textView: NSView? - private var isViewReady = false - private var findQueryText: String = "" // Store search text at panel level - private var eventMonitor: Any? - - init(delegate: FindPanelDelegate?, textView: NSView?) { - self.findDelegate = delegate - self.textView = textView - super.init(frame: .zero) - - viewModel = FindPanelViewModel(delegate: findDelegate) - viewModel.findText = findQueryText // Initialize with stored value - hostingView = NSHostingView(rootView: FindPanelView(viewModel: viewModel)) - hostingView.translatesAutoresizingMaskIntoConstraints = false - - // Make the NSHostingView transparent - hostingView.wantsLayer = true - hostingView.layer?.backgroundColor = .clear - - // Make the FindPanel itself transparent - self.wantsLayer = true - self.layer?.backgroundColor = .clear - - addSubview(hostingView) - - NSLayoutConstraint.activate([ - hostingView.topAnchor.constraint(equalTo: topAnchor), - hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - - self.translatesAutoresizingMaskIntoConstraints = false - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - if !isViewReady && superview != nil { - isViewReady = true - viewModel.startObservingFindText() - } - } - - deinit { - removeEventMonitor() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var fittingSize: NSSize { - hostingView.fittingSize - } - - // MARK: - First Responder Management - - override func becomeFirstResponder() -> Bool { - viewModel.setFocus(true) - return true - } - - override func resignFirstResponder() -> Bool { - viewModel.setFocus(false) - return true - } - - // MARK: - Event Monitor Management - - func addEventMonitor() { - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in - if event.keyCode == 53 { // if esc pressed - self.dismiss() - return nil // do not play "beep" sound - } - return event - } - } - - func removeEventMonitor() { - if let monitor = eventMonitor { - NSEvent.removeMonitor(monitor) - eventMonitor = nil - } - } - - // MARK: - Public Methods - - func dismiss() { - viewModel.onDismiss() - } - - func updateMatchCount(_ count: Int) { - viewModel.updateMatchCount(count) - } - - // MARK: - Search Text Management - - func updateSearchText(_ text: String) { - findQueryText = text - viewModel.findText = text - findDelegate?.findPanelDidUpdate(text) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift new file mode 100644 index 000000000..383d2305d --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelContent.swift @@ -0,0 +1,54 @@ +// +// FindPanelContent.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 5/2/25. +// + +import SwiftUI + +/// A SwiftUI view that provides the main content layout for the find and replace panel. +/// +/// The `FindPanelContent` view is responsible for: +/// - Arranging the find and replace text fields in a vertical stack +/// - Arranging the control buttons in a vertical stack +/// - Handling the layout differences between find and replace modes +/// - Supporting both full and condensed layouts +/// +/// The view is designed to be used within `FindPanelView` and adapts its layout based on the +/// available space and current mode (find or replace). +struct FindPanelContent: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + var findModePickerWidth: Binding + var condensed: Bool + + var body: some View { + HStack(spacing: 5) { + VStack(alignment: .leading, spacing: 4) { + FindSearchField( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: findModePickerWidth, + condensed: condensed + ) + if viewModel.mode == .replace { + ReplaceSearchField( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: findModePickerWidth, + condensed: condensed + ) + } + } + VStack(alignment: .leading, spacing: 4) { + FindControls(viewModel: viewModel, condensed: condensed) + if viewModel.mode == .replace { + Spacer(minLength: 0) + ReplaceControls(viewModel: viewModel, condensed: condensed) + } + } + .fixedSize() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift new file mode 100644 index 000000000..dedb9bdbe --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -0,0 +1,71 @@ +// +// FindPanelHostingView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import SwiftUI +import AppKit +import Combine + +/// A subclass of `NSHostingView` that hosts the SwiftUI `FindPanelView` in an +/// AppKit context. +/// +/// The `FindPanelHostingView` class is responsible for: +/// - Bridging between SwiftUI and AppKit by hosting the FindPanelView +/// - Managing keyboard event monitoring for the escape key +/// - Handling the dismissal of the find panel +/// - Providing proper view lifecycle management +/// - Ensuring proper cleanup of event monitors +/// +/// This class is essential for integrating the SwiftUI-based find panel into the AppKit-based +/// text editor. +final class FindPanelHostingView: NSHostingView { + private weak var viewModel: FindPanelViewModel? + + private var eventMonitor: Any? + + init(viewModel: FindPanelViewModel) { + self.viewModel = viewModel + super.init(rootView: FindPanelView(viewModel: viewModel)) + + self.translatesAutoresizingMaskIntoConstraints = false + + self.wantsLayer = true + self.layer?.backgroundColor = .clear + + self.translatesAutoresizingMaskIntoConstraints = false + } + + @MainActor @preconcurrency required init(rootView: FindPanelView) { + super.init(rootView: rootView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + removeEventMonitor() + } + + // MARK: - Event Monitor Management + + func addEventMonitor() { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in + if event.keyCode == 53 { // if esc pressed + self.viewModel?.dismiss?() + return nil // do not play "beep" sound + } + return event + } + } + + func removeEventMonitor() { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index d18b33cc5..c32be4b8b 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -7,73 +7,136 @@ import SwiftUI import AppKit +import CodeEditSymbols +import CodeEditTextView +/// A SwiftUI view that provides a find and replace interface for the text editor. +/// +/// The `FindPanelView` is the main container view for the find and replace functionality. It manages: +/// - The find/replace mode switching +/// - Focus management between find and replace fields +/// - Panel height adjustments based on mode +/// - Search text changes and match highlighting +/// - Case sensitivity and wrap-around settings +/// +/// The view automatically adapts its layout based on available space using `ViewThatFits`, providing +/// both a full and condensed layout option. struct FindPanelView: View { + /// Represents the current focus state of the find panel + enum FindPanelFocus: Equatable { + /// The find text field is focused + case find + /// The replace text field is focused + case replace + } + @Environment(\.controlActiveState) var activeState @ObservedObject var viewModel: FindPanelViewModel - @FocusState private var isFocused: Bool + @State private var findModePickerWidth: CGFloat = 1.0 + + @FocusState private var focus: FindPanelFocus? var body: some View { - HStack(spacing: 5) { - PanelTextField( - "Search...", - text: $viewModel.findText, - leadingAccessories: { - Image(systemName: "magnifyingglass") - .padding(.leading, 8) - .foregroundStyle(activeState == .inactive ? .tertiary : .secondary) - .font(.system(size: 12)) - .frame(width: 16, height: 20) - }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", - clearable: true + ViewThatFits { + FindPanelContent( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: $findModePickerWidth, + condensed: false ) - .focused($isFocused) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() - } - } - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) - } - .onSubmit { - viewModel.onSubmit() - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.prevButtonClicked) { - Image(systemName: "chevron.left") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.nextButtonClicked) { - Image(systemName: "chevron.right") - .opacity(viewModel.matchCount == 0 ? 0.33 : 1) - .padding(.horizontal, 5) - } - .disabled(viewModel.matchCount == 0) + FindPanelContent( + viewModel: viewModel, + focus: $focus, + findModePickerWidth: $findModePickerWidth, + condensed: true + ) + } + .padding(.horizontal, 5) + .frame(height: viewModel.panelHeight) + .background(.bar) + .onChange(of: focus) { newValue in + viewModel.isFocused = newValue != nil + } + .onChange(of: viewModel.findText) { _ in + viewModel.findTextDidChange() + } + .onChange(of: viewModel.wrapAround) { _ in + viewModel.find() + } + .onChange(of: viewModel.matchCase) { _ in + viewModel.find() + } + .onChange(of: viewModel.isFocused) { newValue in + if newValue { + if focus == nil { + focus = .find } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - Button(action: viewModel.onDismiss) { - Text("Done") - .padding(.horizontal, 5) + if !viewModel.findText.isEmpty { + // Restore emphases when focus is regained and we have search text + viewModel.addMatchEmphases(flashCurrent: false) } - .buttonStyle(PanelButtonStyle()) + } else { + viewModel.clearMatchEmphases() } } - .padding(.horizontal, 5) - .frame(height: FindPanel.height) - .background(.bar) } } + +/// A preference key used to track the width of the find mode picker +private struct FindModePickerWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +/// A mock target for previews that implements the FindPanelTarget protocol +class MockFindPanelTarget: FindPanelTarget { + var textView: TextView! + var findPanelTargetView: NSView = NSView() + var cursorPositions: [CursorPosition] = [] + + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) {} + func updateCursorPosition() {} + func findPanelWillShow(panelHeight: CGFloat) {} + func findPanelWillHide(panelHeight: CGFloat) {} + func findPanelModeDidChange(to mode: FindPanelMode) {} +} + +#Preview("Find Mode") { + FindPanelView(viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }()) + .frame(width: 400) + .padding() +} + +#Preview("Replace Mode") { + FindPanelView(viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.mode = .replace + vm.findText = "example" + vm.replaceText = "test" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }()) + .frame(width: 400) + .padding() +} + +#Preview("Condensed Layout") { + FindPanelView(viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }()) + .frame(width: 300) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift deleted file mode 100644 index e8435f7a8..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// FindPanelViewModel.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import SwiftUI -import Combine - -class FindPanelViewModel: ObservableObject { - @Published var findText: String = "" - @Published var matchCount: Int = 0 - @Published var isFocused: Bool = false - - private weak var delegate: FindPanelDelegate? - - init(delegate: FindPanelDelegate?) { - self.delegate = delegate - } - - func startObservingFindText() { - if !findText.isEmpty { - delegate?.findPanelDidUpdate(findText) - } - } - - func onFindTextChange(_ text: String) { - delegate?.findPanelDidUpdate(text) - } - - func onSubmit() { - delegate?.findPanelOnSubmit() - } - - func onDismiss() { - delegate?.findPanelOnDismiss() - } - - func setFocus(_ focused: Bool) { - isFocused = focused - if focused && !findText.isEmpty { - // Restore emphases when focus is regained and we have search text - delegate?.findPanelDidUpdate(findText) - } - } - - func updateMatchCount(_ count: Int) { - matchCount = count - } - - func removeEmphasis() { - delegate?.findPanelClearEmphasis() - } - - func prevButtonClicked() { - delegate?.findPanelPrevButtonClicked() - } - - func nextButtonClicked() { - delegate?.findPanelNextButtonClicked() - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift new file mode 100644 index 000000000..0d81ad81f --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -0,0 +1,156 @@ +// +// FindSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +/// A SwiftUI view that provides the search text field for the find panel. +/// +/// The `FindSearchField` view is responsible for: +/// - Displaying and managing the find text input field +/// - Showing the find mode picker (find/replace) in both condensed and full layouts +/// - Providing case sensitivity toggle +/// - Displaying match count information +/// - Handling keyboard navigation (Enter to find next) +/// +/// The view adapts its layout based on the `condensed` parameter, providing a more compact +/// interface when space is limited. +struct FindSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + var condensed: Bool + + private var helperText: String? { + if viewModel.findText.isEmpty { + nil + } else if condensed { + "\(viewModel.matchCount)" + } else { + "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")" + } + } + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.findText, + leadingAccessories: { + if condensed { + Color.clear + .frame(width: 12, height: 12) + .foregroundStyle(.secondary) + .padding(.leading, 8) + .overlay(alignment: .leading) { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround + ) + } + .clipped() + .overlay(alignment: .trailing) { + Image(systemName: "chevron.down") + .foregroundStyle(.secondary) + .font(.system(size: 5, weight: .black)) + .padding(.leading, 4).padding(.trailing, -4) + } + } else { + HStack(spacing: 0) { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround + ) + .background(GeometryReader { geometry in + Color.clear.onAppear { + findModePickerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + findModePickerWidth = newWidth + } + }) + .focusable(false) + Divider() + } + } + }, + trailingAccessories: { + Divider() + Toggle(isOn: $viewModel.matchCase, label: { + Image(systemName: "textformat") + .font(.system( + size: 11, + weight: viewModel.matchCase ? .bold : .medium + )) + .foregroundStyle( + Color( + nsColor: viewModel.matchCase + ? .controlAccentColor + : .labelColor + ) + ) + .frame(width: 30, height: 20) + }) + .toggleStyle(.icon) + }, + helperText: helperText, + clearable: true + ) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .focused($focus, equals: .find) + .onSubmit { + viewModel.moveToNextMatch() + } + } +} + +#Preview("Find Search Field - Full") { + @FocusState var focus: FindPanelView.FindPanelFocus? + FindSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} + +#Preview("Find Search Field - Condensed") { + @FocusState var focus: FindPanelView.FindPanelFocus? + FindSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: true + ) + .frame(width: 200) + .padding() +} + +#Preview("Find Search Field - Empty") { + @FocusState var focus: FindPanelView.FindPanelFocus? + FindSearchField( + viewModel: FindPanelViewModel(target: MockFindPanelTarget()), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift new file mode 100644 index 000000000..6b62348f0 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceControls.swift @@ -0,0 +1,117 @@ +// +// ReplaceControls.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/30/25. +// + +import SwiftUI + +/// A SwiftUI view that provides the replace controls for the find panel. +/// +/// The `ReplaceControls` view is responsible for: +/// - Displaying replace and replace all buttons +/// - Managing button states based on find text and match count +/// - Adapting button appearance between condensed and full layouts +/// - Providing tooltips for button actions +/// - Handling replace operations through the view model +/// +/// The view is only shown when the find panel is in replace mode and works in conjunction +/// with the replace text field to perform text replacements. +struct ReplaceControls: View { + @ObservedObject var viewModel: FindPanelViewModel + var condensed: Bool + + var shouldDisableSingle: Bool { + !viewModel.isFocused || viewModel.findText.isEmpty || viewModel.matchesEmpty + } + + var shouldDisableAll: Bool { + viewModel.findText.isEmpty || viewModel.matchesEmpty + } + + var body: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.replace() + } label: { + Group { + if condensed { + Image(systemName: "arrow.turn.up.right") + } else { + Text("Replace") + } + } + .opacity(shouldDisableSingle ? 0.33 : 1) + } + .help(condensed ? "Replace" : "") + .disabled(shouldDisableSingle) + .frame(maxWidth: .infinity) + + Divider().overlay(Color(nsColor: .tertiaryLabelColor)) + + Button { + viewModel.replaceAll() + } label: { + Group { + if condensed { + Image(systemName: "text.insert") + } else { + Text("All") + } + } + .opacity(shouldDisableAll ? 0.33 : 1) + } + .help(condensed ? "Replace All" : "") + .disabled(shouldDisableAll) + .frame(maxWidth: .infinity) + } + .controlGroupStyle(PanelControlGroupStyle()) + } + .fixedSize(horizontal: false, vertical: true) + } +} + +#Preview("Replace Controls - Full") { + ReplaceControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.replaceText = "replacement" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: false + ) + .padding() +} + +#Preview("Replace Controls - Condensed") { + ReplaceControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.replaceText = "replacement" + vm.findMatches = [NSRange(location: 0, length: 7)] + vm.currentFindMatchIndex = 0 + return vm + }(), + condensed: true + ) + .padding() +} + +#Preview("Replace Controls - No Matches") { + ReplaceControls( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.findText = "example" + vm.replaceText = "replacement" + return vm + }(), + condensed: false + ) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift new file mode 100644 index 000000000..87e470b26 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -0,0 +1,99 @@ +// +// ReplaceSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +/// A SwiftUI view that provides the replace text field for the find panel. +/// +/// The `ReplaceSearchField` view is responsible for: +/// - Displaying and managing the replace text input field +/// - Showing a visual indicator (pencil icon) for the replace field +/// - Adapting its layout between condensed and full modes +/// - Maintaining focus state for keyboard navigation +/// +/// The view is only shown when the find panel is in replace mode and adapts its layout +/// based on the `condensed` parameter to match the find field's appearance. +struct ReplaceSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + var condensed: Bool + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.replaceText, + leadingAccessories: { + if condensed { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .padding(.leading, 8) + } else { + HStack(spacing: 0) { + HStack(spacing: 0) { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .padding(.leading, 8) + .padding(.trailing, 5) + Text("With") + } + .frame(width: findModePickerWidth, alignment: .leading) + Divider() + } + } + }, + clearable: true + ) + .controlSize(.small) + .fixedSize(horizontal: false, vertical: true) + .focused($focus, equals: .replace) + } +} + +#Preview("Replace Search Field - Full") { + @FocusState var focus: FindPanelView.FindPanelFocus? + ReplaceSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.replaceText = "replacement" + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} + +#Preview("Replace Search Field - Condensed") { + @FocusState var focus: FindPanelView.FindPanelFocus? + ReplaceSearchField( + viewModel: { + let vm = FindPanelViewModel(target: MockFindPanelTarget()) + vm.replaceText = "replacement" + return vm + }(), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: true + ) + .frame(width: 200) + .padding() +} + +#Preview("Replace Search Field - Empty") { + @FocusState var focus: FindPanelView.FindPanelFocus? + ReplaceSearchField( + viewModel: FindPanelViewModel(target: MockFindPanelTarget()), + focus: $focus, + findModePickerWidth: .constant(100), + condensed: false + ) + .frame(width: 300) + .padding() +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift new file mode 100644 index 000000000..adcebcd8e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift @@ -0,0 +1,64 @@ +// +// FindPanelViewModel+Emphasis.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import CodeEditTextView + +extension FindPanelViewModel { + func addMatchEmphases(flashCurrent: Bool) { + guard let target = target, let emphasisManager = target.textView.emphasisManager else { + return + } + + // Clear existing emphases + emphasisManager.removeEmphases(for: EmphasisGroup.find) + + // Create emphasis with the nearest match as active + let emphases = findMatches.enumerated().map { index, range in + Emphasis( + range: range, + style: .standard, + flash: flashCurrent && index == currentFindMatchIndex, + inactive: index != currentFindMatchIndex, + selectInDocument: index == currentFindMatchIndex + ) + } + + // Add all emphases + emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) + } + + func flashCurrentMatch() { + guard let target = target, + let emphasisManager = target.textView.emphasisManager, + let currentFindMatchIndex else { + return + } + + let currentMatch = findMatches[currentFindMatchIndex] + + // Clear existing emphases + emphasisManager.removeEmphases(for: EmphasisGroup.find) + + // Create emphasis with the nearest match as active + let emphasis = ( + Emphasis( + range: currentMatch, + style: .standard, + flash: true, + inactive: false, + selectInDocument: true + ) + ) + + // Add the emphasis + emphasisManager.addEmphases([emphasis], for: EmphasisGroup.find) + } + + func clearMatchEmphases() { + target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift new file mode 100644 index 000000000..ea75fff2c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -0,0 +1,88 @@ +// +// FindPanelViewModel+Find.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation + +extension FindPanelViewModel { + // MARK: - Find + + /// Performs a find operation on the find target and updates both the ``findMatches`` array and the emphasis + /// manager's emphases. + func find() { + // Don't find if target isn't ready or the query is empty + guard let target = target, !findText.isEmpty else { + self.findMatches = [] + return + } + + // Set case sensitivity based on matchCase property + let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: findText) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { + self.findMatches = [] + self.currentFindMatchIndex = 0 + return + } + + let text = target.textView.string + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + + self.findMatches = matches.map(\.range) + + // Find the nearest match to the current cursor position + currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 + + // Only add emphasis layers if the find panel is focused + if isFocused { + addMatchEmphases(flashCurrent: false) + } + } + + // MARK: - Get Nearest Emphasis Index + + private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { + // order the array as follows + // Found: 1 -> 2 -> 3 -> 4 + // Cursor: | + // Result: 3 -> 4 -> 1 -> 2 + guard let cursorPosition = target?.cursorPositions.first else { return nil } + let start = cursorPosition.range.location + + var left = 0 + var right = matchRanges.count - 1 + var bestIndex = -1 + var bestDiff = Int.max // Stores the closest difference + + while left <= right { + let mid = left + (right - left) / 2 + let midStart = matchRanges[mid].location + let diff = abs(midStart - start) + + // If it's an exact match, return immediately + if diff == 0 { + return mid + } + + // If this is the closest so far, update the best index + if diff < bestDiff { + bestDiff = diff + bestIndex = mid + } + + // Move left or right based on the cursor position + if midStart < start { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return bestIndex >= 0 ? bestIndex : nil + } + +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift new file mode 100644 index 000000000..726598b7c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -0,0 +1,70 @@ +// +// FindPanelViewModel+Move.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import AppKit + +extension FindPanelViewModel { + func moveToNextMatch() { + moveMatch(forwards: true) + } + + func moveToPreviousMatch() { + moveMatch(forwards: false) + } + + private func moveMatch(forwards: Bool) { + guard let target = target else { return } + + guard !findMatches.isEmpty else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + // From here on out we want to emphasize the result no matter what + defer { + if isTargetFirstResponder { + flashCurrentMatch() + } else { + addMatchEmphases(flashCurrent: isTargetFirstResponder) + } + } + + guard let currentFindMatchIndex else { + self.currentFindMatchIndex = 0 + return + } + + let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 + guard !isAtLimit || wrapAround else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + self.currentFindMatchIndex = if forwards { + (currentFindMatchIndex + 1) % findMatches.count + } else { + (currentFindMatchIndex - 1 + (findMatches.count)) % findMatches.count + } + if isAtLimit { + showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) + } + } + + private func showWrapNotification(forwards: Bool, error: Bool, targetView: NSView) { + if error { + NSSound.beep() + } + BezelNotification.show( + symbolName: error ? + forwards ? "arrow.down.to.line" : "arrow.up.to.line" + : forwards + ? "arrow.trianglehead.topright.capsulepath.clockwise" + : "arrow.trianglehead.bottomleft.capsulepath.clockwise", + over: targetView + ) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift new file mode 100644 index 000000000..6d23af408 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -0,0 +1,88 @@ +// +// FindPanelViewModel+Replace.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation +import CodeEditTextView + +extension FindPanelViewModel { + /// Replace one or all ``findMatches`` with the contents of ``replaceText``. + /// - Parameter all: If true, replaces all matches instead of just the selected one. + func replace() { + guard let target = target, + let currentFindMatchIndex, + !findMatches.isEmpty else { + return + } + + replaceMatch(index: currentFindMatchIndex, textView: target.textView, matches: &findMatches) + + self.findMatches = findMatches.enumerated().filter({ $0.offset != currentFindMatchIndex }).map(\.element) + + // Update currentFindMatchIndex based on wrapAround setting + if findMatches.isEmpty { + self.currentFindMatchIndex = nil + } else if wrapAround { + self.currentFindMatchIndex = currentFindMatchIndex % findMatches.count + } else { + // If we're at the end and not wrapping, stay at the end + self.currentFindMatchIndex = min(currentFindMatchIndex, findMatches.count - 1) + } + + // Update the emphases + addMatchEmphases(flashCurrent: true) + } + + func replaceAll() { + guard let target = target, + !findMatches.isEmpty else { + return + } + + target.textView.undoManager?.beginUndoGrouping() + target.textView.textStorage.beginEditing() + + var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) + for (idx, _) in sortedMatches.enumerated().reversed() { + replaceMatch(index: idx, textView: target.textView, matches: &sortedMatches) + } + + target.textView.textStorage.endEditing() + target.textView.undoManager?.endUndoGrouping() + + if let lastMatch = sortedMatches.last { + target.setCursorPositions( + [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], + scrollToVisible: true + ) + } + + self.findMatches = [] + self.currentFindMatchIndex = nil + + // Update the emphases + addMatchEmphases(flashCurrent: true) + } + + /// Replace a single match in the text view, updating all other find matches with any length changes. + /// - Parameters: + /// - index: The index of the match to replace in the `matches` array. + /// - textView: The text view to replace characters in. + /// - matches: The array of matches to use and update. + private func replaceMatch(index: Int, textView: TextView, matches: inout [NSRange]) { + let range = matches[index] + // Set cursor positions to the match range + textView.replaceCharacters(in: range, with: replaceText) + + // Adjust the length of the replacement + let lengthDiff = replaceText.utf16.count - range.length + + // Update all match ranges after the current match + for idx in matches.dropFirst(index + 1).indices { + matches[idx].location -= lengthDiff + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift new file mode 100644 index 000000000..d6975d112 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -0,0 +1,96 @@ +// +// FindPanelViewModel.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import Combine +import CodeEditTextView + +class FindPanelViewModel: ObservableObject { + weak var target: FindPanelTarget? + var dismiss: (() -> Void)? + + @Published var findMatches: [NSRange] = [] + @Published var currentFindMatchIndex: Int? + @Published var isShowingFindPanel: Bool = false + + @Published var findText: String = "" + @Published var replaceText: String = "" + @Published var mode: FindPanelMode = .find { + didSet { + self.target?.findPanelModeDidChange(to: mode) + } + } + + @Published var isFocused: Bool = false + + @Published var matchCase: Bool = false + @Published var wrapAround: Bool = true + + /// The height of the find panel. + var panelHeight: CGFloat { + return mode == .replace ? 54 : 28 + } + + /// The number of current find matches. + var matchCount: Int { + findMatches.count + } + + var matchesEmpty: Bool { + matchCount == 0 + } + + var isTargetFirstResponder: Bool { + target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView + } + + init(target: FindPanelTarget) { + self.target = target + + // Add notification observer for text changes + if let textViewController = target as? TextViewController { + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange), + name: TextView.textDidChangeNotification, + object: textViewController.textView + ) + } + } + + // MARK: - Text Listeners + + /// Find target's text content changed, we need to re-search the contents and emphasize results. + @objc private func textDidChange() { + // Only update if we have find text + if !findText.isEmpty { + find() + } + } + + /// The contents of the find search field changed, trigger related events. + func findTextDidChange() { + // Check if this update was triggered by a return key without shift + if let currentEvent = NSApp.currentEvent, + currentEvent.type == .keyDown, + currentEvent.keyCode == 36, // Return key + !currentEvent.modifierFlags.contains(.shift) { + return // Skip find for regular return key + } + + // If the textview is first responder, exit fast + if target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView { + // If the text view has focus, just clear visual emphases but keep our find matches + target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + return + } + + // Clear existing emphases before performing new find + target?.textView.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + find() + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 0d9cf5b04..2a5125789 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -10,7 +10,7 @@ import CodeEditTextView import CodeEditTextViewObjC public protocol GutterViewDelegate: AnyObject { - func gutterViewWidthDidUpdate(newWidth: CGFloat) + func gutterViewWidthDidUpdate() } /// The gutter view displays line numbers that match the text view's line indexes. @@ -57,6 +57,10 @@ public class GutterView: NSView { @Invalidating(.display) var backgroundEdgeInsets: EdgeInsets = EdgeInsets(leading: 0, trailing: 8) + /// The leading padding for the folding ribbon from the line numbers. + @Invalidating(.display) + var foldingRibbonPadding: CGFloat = 4 + @Invalidating(.display) var backgroundColor: NSColor? = NSColor.controlBackgroundColor @@ -69,12 +73,17 @@ public class GutterView: NSView { @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - /// The required width of the entire gutter, including padding. - private(set) public var gutterWidth: CGFloat = 0 + /// Toggle the visibility of the line fold decoration. + @Invalidating(.display) + public var showFoldingRibbon: Bool = true { + didSet { + foldingRibbon.isHidden = !showFoldingRibbon + } + } private weak var textView: TextView? private weak var delegate: GutterViewDelegate? - private var maxWidth: CGFloat = 0 + private var maxLineNumberWidth: CGFloat = 0 /// The maximum number of digits found for a line number. private var maxLineLength: Int = 0 @@ -91,10 +100,39 @@ public class GutterView: NSView { fontLineHeight = (ascent + descent + leading) } + /// The view that draws the fold decoration in the gutter. + private var foldingRibbon: FoldingRibbonView + + /// Syntax helper for determining the required space for the folding ribbon. + private var foldingRibbonWidth: CGFloat { + if foldingRibbon.isHidden { + 0.0 + } else { + FoldingRibbonView.width + foldingRibbonPadding + } + } + + /// The gutter's y positions start at the top of the document and increase as it moves down the screen. override public var isFlipped: Bool { true } + /// We override this variable so we can update the ``foldingRibbon``'s frame to match the gutter. + override public var frame: NSRect { + get { + super.frame + } + set { + super.frame = newValue + foldingRibbon.frame = NSRect( + x: newValue.width - edgeInsets.trailing - foldingRibbonWidth + foldingRibbonPadding, + y: 0.0, + width: foldingRibbonWidth, + height: newValue.height + ) + } + } + public init( font: NSFont, textColor: NSColor, @@ -108,6 +146,8 @@ public class GutterView: NSView { self.textView = textView self.delegate = delegate + foldingRibbon = FoldingRibbonView(textView: textView, foldProvider: nil) + super.init(frame: .zero) clipsToBounds = true wantsLayer = true @@ -115,6 +155,8 @@ public class GutterView: NSView { translatesAutoresizingMaskIntoConstraints = false layer?.masksToBounds = true + addSubview(foldingRibbon) + NotificationCenter.default.addObserver( forName: TextSelectionManager.selectionChangedNotification, object: nil, @@ -124,22 +166,17 @@ public class GutterView: NSView { } } - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - /// Updates the width of the gutter if needed. + /// Updates the width of the gutter if needed to match the maximum line number found as well as the folding ribbon. func updateWidthIfNeeded() { guard let textView else { return } let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: textColor ] - let originalMaxWidth = maxWidth // Reserve at least 3 digits of space no matter what let lineStorageDigits = max(3, String(textView.layoutManager.lineCount).count) @@ -149,27 +186,36 @@ public class GutterView: NSView { NSAttributedString(string: String(repeating: "0", count: lineStorageDigits), attributes: attributes) ) let width = CTLineGetTypographicBounds(maxCtLine, nil, nil, nil) - maxWidth = max(maxWidth, width) + maxLineNumberWidth = max(maxLineNumberWidth, width) maxLineLength = lineStorageDigits } - if originalMaxWidth != maxWidth { - gutterWidth = maxWidth + edgeInsets.horizontal - delegate?.gutterViewWidthDidUpdate(newWidth: maxWidth + edgeInsets.horizontal) + let newWidth = maxLineNumberWidth + edgeInsets.horizontal + foldingRibbonWidth + if frame.size.width != newWidth { + frame.size.width = newWidth + delegate?.gutterViewWidthDidUpdate() } } - private func drawBackground(_ context: CGContext) { + /// Fills the gutter background color. + /// - Parameters: + /// - context: The drawing context to draw in. + /// - dirtyRect: A rect to draw in, received from ``draw(_:)``. + private func drawBackground(_ context: CGContext, dirtyRect: NSRect) { guard let backgroundColor else { return } - let xPos = backgroundEdgeInsets.leading - let width = gutterWidth - backgroundEdgeInsets.trailing + let minX = max(backgroundEdgeInsets.leading, dirtyRect.minX) + let maxX = min(frame.width - backgroundEdgeInsets.trailing - foldingRibbonWidth, dirtyRect.maxX) + let width = maxX - minX context.saveGState() context.setFillColor(backgroundColor.cgColor) - context.fill(CGRect(x: xPos, y: 0, width: width, height: frame.height)) + context.fill(CGRect(x: minX, y: dirtyRect.minY, width: width, height: dirtyRect.height)) context.restoreGState() } + /// Draws selected line backgrounds from the text view's selection manager into the gutter view, making the + /// selection background appear seamless between the gutter and text view. + /// - Parameter context: The drawing context to use. private func drawSelectedLines(_ context: CGContext) { guard let textView = textView, let selectionManager = textView.selectionManager, @@ -183,7 +229,7 @@ public class GutterView: NSView { context.setFillColor(selectedLineColor.cgColor) let xPos = backgroundEdgeInsets.leading - let width = gutterWidth - backgroundEdgeInsets.trailing + let width = frame.width - backgroundEdgeInsets.trailing for selection in selectionManager.textSelections where selection.range.isEmpty { guard let line = textView.layoutManager.textLineForOffset(selection.range.location), @@ -205,7 +251,11 @@ public class GutterView: NSView { context.restoreGState() } - private func drawLineNumbers(_ context: CGContext) { + /// Draw line numbers in the gutter, limited to a drawing rect. + /// - Parameters: + /// - context: The drawing context to draw in. + /// - dirtyRect: A rect to draw in, received from ``draw(_:)``. + private func drawLineNumbers(_ context: CGContext, dirtyRect: NSRect) { guard let textView = textView else { return } var attributes: [NSAttributedString.Key: Any] = [.font: font] @@ -219,9 +269,10 @@ public class GutterView: NSView { } context.saveGState() + context.clip(to: dirtyRect) context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) - for linePosition in textView.layoutManager.visibleLines() { + for linePosition in textView.layoutManager.linesStartingAt(dirtyRect.minY, until: dirtyRect.maxY) { if selectionRangeMap.intersects(integersIn: linePosition.range) { attributes[.foregroundColor] = selectedLineTextColor ?? textColor } else { @@ -238,7 +289,7 @@ public class GutterView: NSView { let yPos = linePosition.yPos + ascent + (fragment?.heightDifference ?? 0)/2 + fontHeightDifference // Leading padding + (width - linewidth) - let xPos = edgeInsets.leading + (maxWidth - lineNumberWidth) + let xPos = edgeInsets.leading + (maxLineNumberWidth - lineNumberWidth) ContextSetHiddenSmoothingStyle(context, 16) @@ -249,18 +300,20 @@ public class GutterView: NSView { context.restoreGState() } + override public func setNeedsDisplay(_ invalidRect: NSRect) { + updateWidthIfNeeded() + super.setNeedsDisplay(invalidRect) + } + override public func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return } - CATransaction.begin() - superview?.clipsToBounds = false - superview?.layer?.masksToBounds = false - updateWidthIfNeeded() - drawBackground(context) + context.saveGState() + drawBackground(context, dirtyRect: dirtyRect) drawSelectedLines(context) - drawLineNumbers(context) - CATransaction.commit() + drawLineNumbers(context, dirtyRect: dirtyRect) + context.restoreGState() } deinit { diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift new file mode 100644 index 000000000..03dbd9fa1 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift @@ -0,0 +1,34 @@ +// +// IndentationLineFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +final class IndentationLineFoldProvider: LineFoldProvider { + func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { + guard let linePosition = layoutManager.textLineForIndex(lineNumber), + let indentLevel = indentLevelForPosition(linePosition, textStorage: textStorage) else { + return nil + } + + return indentLevel + } + + private func indentLevelForPosition( + _ position: TextLineStorage.TextLinePosition, + textStorage: NSTextStorage + ) -> Int? { + guard let substring = textStorage.substring(from: position.range) else { + return nil + } + + return substring.utf16 // Keep NSString units + .enumerated() + .first(where: { UnicodeScalar($0.element)?.properties.isWhitespace != true })? + .offset + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift new file mode 100644 index 000000000..714a48a06 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift @@ -0,0 +1,25 @@ +// +// FoldRange.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import Foundation + +/// Represents a recursive folded range +class FoldRange { + var lineRange: ClosedRange + var range: NSRange + /// Ordered array of ranges that are nested in this fold. + var subFolds: [FoldRange] + + weak var parent: FoldRange? + + init(lineRange: ClosedRange, range: NSRange, parent: FoldRange?, subFolds: [FoldRange]) { + self.lineRange = lineRange + self.range = range + self.subFolds = subFolds + self.parent = parent + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift new file mode 100644 index 000000000..d7d8543bf --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -0,0 +1,210 @@ +// +// FoldingRibbonView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/6/25. +// + +import Foundation +import AppKit +import CodeEditTextView + +#warning("Replace before release") +fileprivate let demoFoldProvider = IndentationLineFoldProvider() + +/// Displays the code folding ribbon in the ``GutterView``. +/// +/// This view draws its contents +class FoldingRibbonView: NSView { + static let width: CGFloat = 7.0 + + private var model: LineFoldingModel + private var hoveringLine: Int? + + @Invalidating(.display) + var backgroundColor: NSColor = NSColor.controlBackgroundColor + + @Invalidating(.display) + var markerColor = NSColor(name: nil) { appearance in + return switch appearance.name { + case .aqua: + NSColor(deviceWhite: 0.0, alpha: 0.1) + case .darkAqua: + NSColor(deviceWhite: 1.0, alpha: 0.1) + default: + NSColor() + } + }.cgColor + + @Invalidating(.display) + var markerBorderColor = NSColor(name: nil) { appearance in + return switch appearance.name { + case .aqua: + NSColor(deviceWhite: 1.0, alpha: 0.4) + case .darkAqua: + NSColor(deviceWhite: 0.0, alpha: 0.4) + default: + NSColor() + } + }.cgColor + + override public var isFlipped: Bool { + true + } + + init(textView: TextView, foldProvider: LineFoldProvider?) { + #warning("Replace before release") + self.model = LineFoldingModel( + textView: textView, + foldProvider: foldProvider ?? demoFoldProvider + ) + super.init(frame: .zero) + layerContentsRedrawPolicy = .onSetNeedsDisplay + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateTrackingAreas() { + trackingAreas.forEach(removeTrackingArea) + let area = NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .activeInKeyWindow], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + } + + override func mouseMoved(with event: NSEvent) { + let pointInView = convert(event.locationInWindow, from: nil) + hoveringLine = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index + } + + /// The context in which the fold is being drawn, including the depth and fold range. + struct FoldMarkerDrawingContext { + let range: ClosedRange + let depth: UInt + + /// Increment the depth + func incrementDepth() -> FoldMarkerDrawingContext { + FoldMarkerDrawingContext( + range: range, + depth: depth + 1 + ) + } + } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext, + let layoutManager = model.textView?.layoutManager else { + return + } + + context.saveGState() + context.clip(to: dirtyRect) + + // Find the visible lines in the rect AppKit is asking us to draw. + guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), + let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { + return + } + let lineRange = rangeStart.index...rangeEnd.index + + context.setFillColor(markerColor) + let folds = model.getFolds(in: lineRange) + for fold in folds { + drawFoldMarker( + fold, + markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0), + in: context, + using: layoutManager + ) + } + + context.restoreGState() + } + + /// Draw a single fold marker for a fold. + /// + /// Ensure the correct fill color is set on the drawing context before calling. + /// + /// - Parameters: + /// - fold: The fold to draw. + /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is + /// being hovered. + /// - context: The drawing context to use. + /// - layoutManager: A layout manager used to retrieve position information for lines. + private func drawFoldMarker( + _ fold: FoldRange, + markerContext: FoldMarkerDrawingContext, + in context: CGContext, + using layoutManager: TextLayoutManager + ) { + guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos, + let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else { + return + } + + let maxYPosition = maxPosition.yPos + maxPosition.height + + if false /*model.getCachedDepthAt(lineNumber: hoveringLine ?? -1)*/ { + // TODO: Handle hover state + } else { + let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) + // TODO: Draw a single horizontal line when folds are adjacent + let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5) + + context.addPath(roundedRect.cgPathFallback) + context.drawPath(using: .fill) + + // Add small white line if we're overlapping with other markers + if markerContext.depth != 0 { + drawOutline( + minYPosition: minYPosition, + maxYPosition: maxYPosition, + originalPath: roundedRect, + in: context + ) + } + } + + // Draw subfolds + for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { + drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) + } + } + + /// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator. + /// + /// This function does not change fill colors for the given context. + /// + /// - Parameters: + /// - minYPosition: The minimum y position of the rectangle to outline. + /// - maxYPosition: The maximum y position of the rectangle to outline. + /// - originalPath: The original bezier path for the rounded rectangle. + /// - context: The context to draw in. + private func drawOutline( + minYPosition: CGFloat, + maxYPosition: CGFloat, + originalPath: NSBezierPath, + in context: CGContext + ) { + context.saveGState() + + let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition) + let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4) + + let combined = CGMutablePath() + combined.addPath(roundedRect.cgPathFallback) + combined.addPath(originalPath.cgPathFallback) + + context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) + context.addPath(combined) + context.setFillColor(markerBorderColor) + context.drawPath(using: .eoFill) + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift new file mode 100644 index 000000000..64a15ae71 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift @@ -0,0 +1,13 @@ +// +// LineFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView + +protocol LineFoldProvider: AnyObject { + func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift new file mode 100644 index 000000000..b2e4dfbcf --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -0,0 +1,155 @@ +// +// LineFoldingModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView + +/// # Basic Premise +/// +/// We need to update, delete, or add fold ranges in the invalidated lines. +/// +/// # Implementation +/// +/// - For each line in the document, put its indent level into a list. +/// - Loop through the list, creating nested folds as indents go up and down. +/// +class LineFoldingModel: NSObject, NSTextStorageDelegate { + /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` + /// and ``FoldRange/subFolds``. + private var foldCache: [FoldRange] = [] + + weak var foldProvider: LineFoldProvider? + weak var textView: TextView? + + init(textView: TextView, foldProvider: LineFoldProvider?) { + self.textView = textView + self.foldProvider = foldProvider + super.init() + textView.addStorageDelegate(self) + buildFoldsForDocument() + } + + func getFolds(in lineRange: ClosedRange) -> [FoldRange] { + foldCache.filter({ $0.lineRange.overlaps(lineRange) }) + } + + /// Build out the ``foldCache`` for the entire document. + /// + /// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the + /// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in. + func buildFoldsForDocument() { + guard let textView, let foldProvider else { return } + foldCache.removeAll(keepingCapacity: true) + + var currentFold: FoldRange? + var currentDepth: Int = 0 + for linePosition in textView.layoutManager.linesInRange(textView.documentRange) { + guard let foldDepth = foldProvider.foldLevelAtLine( + linePosition.index, + layoutManager: textView.layoutManager, + textStorage: textView.textStorage + ) else { + continue + } + print(foldDepth, linePosition.index) + // Start a new fold + if foldDepth > currentDepth { + let newFold = FoldRange( + lineRange: (linePosition.index - 1)...(linePosition.index - 1), + range: .zero, + parent: currentFold, + subFolds: [] + ) + if currentDepth == 0 { + foldCache.append(newFold) + } + currentFold?.subFolds.append(newFold) + currentFold = newFold + } else if foldDepth < currentDepth { + // End this fold + if let fold = currentFold { + fold.lineRange = fold.lineRange.lowerBound...linePosition.index + + if foldDepth == 0 { + currentFold = nil + } + } + currentFold = currentFold?.parent + } + + currentDepth = foldDepth + } + } + + func invalidateLine(lineNumber: Int) { + // TODO: Check if we need to rebuild, or even better, incrementally update the tree. + + // Temporary + buildFoldsForDocument() + } + + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters), + let lineNumber = textView?.layoutManager.textLineForOffset(editedRange.location)?.index else { + return + } + invalidateLine(lineNumber: lineNumber) + } + + /// Finds the deepest cached depth of the fold for a line number. + /// - Parameter lineNumber: The line number to query, zero-indexed. + /// - Returns: The deepest cached depth of the fold if it was found. + func getCachedDepthAt(lineNumber: Int) -> Int? { + return findCachedFoldAt(lineNumber: lineNumber)?.depth + } +} + +// MARK: - Search Folds + +private extension LineFoldingModel { + /// Finds the deepest cached fold and depth of the fold for a line number. + /// - Parameter lineNumber: The line number to query, zero-indexed. + /// - Returns: The deepest cached fold and depth of the fold if it was found. + func findCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? { + binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0) + } + + /// A generic function for searching an ordered array of fold ranges. + /// - Returns: The found range and depth it was found at, if it exists. + func binarySearchFoldsArray( + lineNumber: Int, + folds: borrowing [FoldRange], + currentDepth: Int + ) -> (range: FoldRange, depth: Int)? { + var low = 0 + var high = folds.count - 1 + + while low <= high { + let mid = (low + high) / 2 + let fold = folds[mid] + + if fold.lineRange.contains(lineNumber) { + // Search deeper into subFolds, if any + return binarySearchFoldsArray( + lineNumber: lineNumber, + folds: fold.subFolds, + currentDepth: currentDepth + 1 + ) ?? (fold, currentDepth) + } else if lineNumber < fold.lineRange.lowerBound { + high = mid - 1 + } else { + low = mid + 1 + } + } + return nil + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index f4de2e376..0a5a050f3 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -21,7 +21,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { range: NSRange, stringRef: NSTextStorage, markedRanges: MarkedRanges?, - breakStrategy: LineBreakStrategy + attachments: [AnyTextAttachment] ) { let maxWidth: CGFloat = if let textView, textView.wrapLines { textView.layoutManager.maxLineLayoutWidth @@ -34,7 +34,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { range: range, stringRef: stringRef, markedRanges: markedRanges, - breakStrategy: breakStrategy + attachments: [] ) // Make all fragments 2px tall @@ -62,6 +62,12 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { // Offset is relative to the whole line, the CTLine is too. - return 8 + (CGFloat(offset - CTLineGetStringRange(lineFragment.ctLine).location) * 1.5) + guard let content = lineFragment.contents.first else { return 0.0 } + switch content.data { + case .text(let ctLine): + return 8 + (CGFloat(offset - CTLineGetStringRange(ctLine).location) * 1.5) + case .attachment: + return 0.0 + } } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index 70fe4d9e6..cd3059eb6 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -24,7 +24,7 @@ extension MinimapView { /// The ``scrollView`` uses the scroll percentage calculated for the first case, and scrolls its content to that /// percentage. The ``scrollView`` is only modified if the minimap is longer than the container view. func updateDocumentVisibleViewPosition() { - guard let textView = textView, let editorScrollView = textView.enclosingScrollView, let layoutManager else { + guard let textView = textView, let editorScrollView = textView.enclosingScrollView else { return } diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index bb395ee28..68bbdebac 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -1,3 +1,10 @@ +// +// ReformattingGuideView.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/28/25. +// + import AppKit import CodeEditTextView @@ -28,6 +35,10 @@ class ReformattingGuideView: NSView { fatalError("init(coder:) has not been implemented") } + override func hitTest(_ point: NSPoint) -> NSView? { + return nil + } + // Draw the reformatting guide line and shaded area override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) diff --git a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift index beefdd7d4..9c66afc67 100644 --- a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift +++ b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift @@ -33,8 +33,6 @@ struct PanelTextField: View var onClear: (() -> Void) - var hasValue: Bool - init( _ label: String, text: Binding, @@ -43,8 +41,7 @@ struct PanelTextField: View @ViewBuilder trailingAccessories: () -> TrailingAccessories? = { EmptyView() }, helperText: String? = nil, clearable: Bool? = false, - onClear: (() -> Void)? = {}, - hasValue: Bool? = false + onClear: (() -> Void)? = {} ) { self.label = label _text = text @@ -54,28 +51,35 @@ struct PanelTextField: View self.helperText = helperText ?? nil self.clearable = clearable ?? false self.onClear = onClear ?? {} - self.hasValue = hasValue ?? false } @ViewBuilder public func selectionBackground( _ isFocused: Bool = false ) -> some View { - if self.controlActive != .inactive || !text.isEmpty || hasValue { - if isFocused || !text.isEmpty || hasValue { + if self.controlActive != .inactive || !text.isEmpty { + if isFocused || !text.isEmpty { Color(.textBackgroundColor) } else { if colorScheme == .light { - Color.black.opacity(0.06) + // TODO: if over sidebar 0.06 else 0.085 +// Color.black.opacity(0.06) + Color.black.opacity(0.085) } else { - Color.white.opacity(0.24) + // TODO: if over sidebar 0.24 else 0.06 +// Color.white.opacity(0.24) + Color.white.opacity(0.06) } } } else { if colorScheme == .light { - Color.clear + // TODO: if over sidebar 0.0 else 0.06 +// Color.clear + Color.black.opacity(0.06) } else { - Color.white.opacity(0.14) + // TODO: if over sidebar 0.14 else 0.045 +// Color.white.opacity(0.14) + Color.white.opacity(0.045) } } } @@ -98,6 +102,7 @@ struct PanelTextField: View Text(helperText) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } } if clearable == true { @@ -126,7 +131,7 @@ struct PanelTextField: View ) .overlay( RoundedRectangle(cornerRadius: 6) - .stroke(isFocused || !text.isEmpty || hasValue ? .tertiary : .quaternary, lineWidth: 1.25) + .stroke(isFocused || !text.isEmpty ? .tertiary : .quaternary, lineWidth: 1.25) .clipShape(RoundedRectangle(cornerRadius: 6)) .disabled(true) .edgesIgnoringSafeArea(.all) diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift similarity index 89% rename from Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift rename to Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index 956a763d9..89cdf2238 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -5,7 +5,7 @@ import AppKit import SwiftUI import TextStory -// swiftlint:disable all +// swiftlint:disable:next type_body_length final class TextViewControllerTests: XCTestCase { var controller: TextViewController! @@ -32,7 +32,8 @@ final class TextViewControllerTests: XCTestCase { letterSpacing: 1.0, useSystemCursor: false, bracketPairEmphasis: .flash, - showMinimap: true + showMinimap: true, + showFoldingRibbon: true ) controller.loadView() @@ -162,12 +163,13 @@ final class TextViewControllerTests: XCTestCase { controller.findViewController?.showFindPanel(animated: false) // Extra insets do not effect find panel's insets + let findModel = try XCTUnwrap(controller.findViewController) try assertInsetsEqual( scrollView.contentInsets, - NSEdgeInsets(top: 10 + FindPanel.height, left: 0, bottom: 10, right: 0) + NSEdgeInsets(top: 10 + findModel.viewModel.panelHeight, left: 0, bottom: 10, right: 0) ) XCTAssertEqual(controller.findViewController?.findPanelVerticalConstraint.constant, 0) - XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - FindPanel.height) + XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - findModel.viewModel.panelHeight) } func test_editorOverScroll_ZeroCondition() throws { @@ -226,24 +228,27 @@ final class TextViewControllerTests: XCTestCase { // Insert lots of spaces controller.indentOption = .spaces(count: 1000) - controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + 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)) } - func test_letterSpacing() { + func test_letterSpacing() throws { let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) controller.letterSpacing = 1.0 XCTAssertEqual( - controller.attributesFor(nil)[.kern]! as! CGFloat, + try XCTUnwrap(controller.attributesFor(nil)[.kern] as? CGFloat), (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 ) controller.letterSpacing = 2.0 XCTAssertEqual( - controller.attributesFor(nil)[.kern]! as! CGFloat, + try XCTUnwrap(controller.attributesFor(nil)[.kern] as? CGFloat), (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 ) @@ -259,7 +264,7 @@ final class TextViewControllerTests: XCTestCase { controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { @@ -298,7 +303,7 @@ final class TextViewControllerTests: XCTestCase { } func test_findClosingPair() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.textView.string = "{ Lorem Ipsum {} }" var idx: Int? @@ -313,28 +318,40 @@ final class TextViewControllerTests: XCTestCase { // Test extra pair controller.textView.string = "{ Loren Ipsum {}} }" idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) - XCTAssert(idx == 16, "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`") + XCTAssert( + idx == 16, + "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`" + ) // Text extra pair backwards controller.textView.string = "{ Loren Ipsum {{} }" idx = controller.findClosingPair("}", "{", from: 18, limit: 0, reverse: true) - XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") + XCTAssert( + idx == 14, + "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`" + ) // Test missing pair controller.textView.string = "{ Loren Ipsum { }" idx = controller.findClosingPair("{", "}", from: 1, limit: 17, reverse: false) - XCTAssert(idx == nil, "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + XCTAssert( + idx == nil, + "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`" + ) // Test missing pair backwards controller.textView.string = " Loren Ipsum {} }" idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) - XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + XCTAssert( + idx == nil, + "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`" + ) } // MARK: Set Text func test_setText() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.textView.string = "Hello World" controller.textView.selectionManager.setSelectedRange(NSRange(location: 1, length: 2)) @@ -354,7 +371,7 @@ final class TextViewControllerTests: XCTestCase { // MARK: Cursor Positions func test_cursorPositionRangeInit() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.setText("Hello World") // Test adding a position returns a valid one @@ -395,7 +412,7 @@ final class TextViewControllerTests: XCTestCase { } func test_cursorPositionRowColInit() { - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.setText("Hello World") // Test adding a position returns a valid one @@ -460,5 +477,21 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) } + + // MARK: Folding Ribbon + + func test_foldingRibbonToggle() { + controller.setText("Hello World") + controller.showFoldingRibbon = false + XCTAssertFalse(controller.gutterView.showFoldingRibbon) + controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass + let noRibbonWidth = controller.gutterView.frame.width + + controller.showFoldingRibbon = true + XCTAssertTrue(controller.gutterView.showFoldingRibbon) + controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass + XCTAssertEqual(controller.gutterView.frame.width, noRibbonWidth + 7.0) + } } -// swiftlint:enable all + +// swiftlint:disable:this file_length diff --git a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift new file mode 100644 index 000000000..ba2eb1530 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift @@ -0,0 +1,42 @@ +// +// FindPanelViewModelTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/25/25. +// + +import Testing +import AppKit +import CodeEditTextView +@testable import CodeEditSourceEditor + +@MainActor +struct FindPanelViewModelTests { + class MockPanelTarget: FindPanelTarget { + var emphasisManager: EmphasisManager? + var text: String = "" + var findPanelTargetView: NSView + var cursorPositions: [CursorPosition] = [] + var textView: TextView! + + @MainActor init() { + findPanelTargetView = NSView() + textView = TextView(string: text) + } + + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) { } + func updateCursorPosition() { } + func findPanelWillShow(panelHeight: CGFloat) { } + func findPanelWillHide(panelHeight: CGFloat) { } + func findPanelModeDidChange(to mode: FindPanelMode) { } + } + + @Test func viewModelHeightUpdates() async throws { + let model = FindPanelViewModel(target: MockPanelTarget()) + model.mode = .find + #expect(model.panelHeight == 28) + + model.mode = .replace + #expect(model.panelHeight == 54) + } +} diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift new file mode 100644 index 000000000..4ba76f767 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -0,0 +1,55 @@ +// +// LineFoldingModelTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import Testing +import AppKit +import CodeEditTextView +@testable import CodeEditSourceEditor + +@Suite +@MainActor +struct LineFoldingModelTests { + /// Makes a fold pattern that increases until halfway through the document then goes back to zero. + class HillPatternFoldProvider: LineFoldProvider { + func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { + let halfLineCount = (layoutManager.lineCount / 2) - 1 + + return if lineNumber > halfLineCount { + layoutManager.lineCount - 2 - lineNumber + } else { + lineNumber + } + } + } + + let textView: TextView + let model: LineFoldingModel + + init() { + textView = TextView(string: "A\nB\nC\nD\nE\nF\n") + textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + textView.updatedViewport(NSRect(x: 0, y: 0, width: 1000, height: 1000)) + model = LineFoldingModel(textView: textView, foldProvider: nil) + } + + /// A little unintuitive but we only expect two folds with this. Our provider goes 0-1-2-2-1-0, but we don't + /// make folds for indent level 0. We also expect folds to start on the lines *before* the indent increases and + /// after it decreases, so the fold covers the start/end of the region being folded. + @Test + func buildFoldsForDocument() throws { + let provider = HillPatternFoldProvider() + model.foldProvider = provider + + model.buildFoldsForDocument() + + let fold = try #require(model.getFolds(in: 0...5).first) + #expect(fold.lineRange == 0...5) + + let innerFold = try #require(fold.subFolds.first) + #expect(innerFold.lineRange == 1...4) + } +} diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 1eb96c0c4..fd4360ad3 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -64,7 +64,8 @@ enum Mock { letterSpacing: 1.0, useSystemCursor: false, bracketPairEmphasis: .flash, - showMinimap: true + showMinimap: true, + showFoldingRibbon: true ) } From ba3023419185c642a1cb5829e6a55ead2b45bf83 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:05:56 -0500 Subject: [PATCH 02/14] Add Hover Interaction to Folding Ribbon (#325) ### Description Adds the hover interaction to the code folding ribbon. Details: - Animates in when entering the fold region. - Does not animate when moving between folds after animation. - Hovered lines are emphasized and not transparent. ### Related Issues * #43 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/164e61e9-07c0-4a0c-814d-7a70226e0136 --------- Co-authored-by: Austin Condiff --- .../xcshareddata/swiftpm/Package.resolved | 8 +- Package.resolved | 8 +- Package.swift | 2 +- .../LineFolding/FoldingRibbonView+Draw.swift | 205 +++++++++++++++++ .../LineFolding/FoldingRibbonView.swift | 206 +++++++----------- .../Gutter/LineFolding/LineFoldingModel.swift | 30 +-- 6 files changed, 314 insertions(+), 145 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift 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 3f475425b..243527a2a 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", - "version" : "0.10.1" + "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", + "version" : "0.11.1" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" } }, { diff --git a/Package.resolved b/Package.resolved index 1d320e4a6..7296d78dd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", - "version" : "0.10.1" + "revision" : "69282e2ea7ad8976b062b945d575da47b61ed208", + "version" : "0.11.1" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", - "version" : "1.1.4" + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" } }, { diff --git a/Package.swift b/Package.swift index 751fc8829..69556c288 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.0" + from: "0.11.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift new file mode 100644 index 000000000..d85a25916 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift @@ -0,0 +1,205 @@ +// +// FoldingRibbonView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +extension FoldingRibbonView { + /// The context in which the fold is being drawn, including the depth and fold range. + struct FoldMarkerDrawingContext { + let range: ClosedRange + let depth: UInt + + /// Increment the depth + func incrementDepth() -> FoldMarkerDrawingContext { + FoldMarkerDrawingContext( + range: range, + depth: depth + 1 + ) + } + } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext, + let layoutManager = model.textView?.layoutManager else { + return + } + + context.saveGState() + context.clip(to: dirtyRect) + + // Find the visible lines in the rect AppKit is asking us to draw. + guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), + let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { + return + } + let lineRange = rangeStart.index...rangeEnd.index + + context.setFillColor(markerColor) + let folds = model.getFolds(in: lineRange) + for fold in folds { + drawFoldMarker( + fold, + markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0), + in: context, + using: layoutManager + ) + } + + context.restoreGState() + } + + /// Draw a single fold marker for a fold. + /// + /// Ensure the correct fill color is set on the drawing context before calling. + /// + /// - Parameters: + /// - fold: The fold to draw. + /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is + /// being hovered. + /// - context: The drawing context to use. + /// - layoutManager: A layout manager used to retrieve position information for lines. + private func drawFoldMarker( + _ fold: FoldRange, + markerContext: FoldMarkerDrawingContext, + in context: CGContext, + using layoutManager: TextLayoutManager + ) { + guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos, + let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else { + return + } + + let maxYPosition = maxPosition.yPos + maxPosition.height + + if let hoveringFold, + hoveringFold.depth == markerContext.depth, + fold.lineRange == hoveringFold.range { + drawHoveredFold( + markerContext: markerContext, + minYPosition: minYPosition, + maxYPosition: maxYPosition, + in: context + ) + } else { + drawNestedFold( + markerContext: markerContext, + minYPosition: minYPosition, + maxYPosition: maxYPosition, + in: context + ) + } + + // Draw subfolds + for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { + drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) + } + } + + private func drawHoveredFold( + markerContext: FoldMarkerDrawingContext, + minYPosition: CGFloat, + maxYPosition: CGFloat, + in context: CGContext + ) { + context.saveGState() + let plainRect = NSRect(x: -2, y: minYPosition, width: 11.0, height: maxYPosition - minYPosition) + let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 11.0 / 2, yRadius: 11.0 / 2) + + context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor) + context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? hoverBorderColor) + context.addPath(roundedRect.cgPathFallback) + context.drawPath(using: .fillStroke) + + // Add the little arrows + drawChevron(in: context, yPosition: minYPosition + 8, pointingUp: false) + drawChevron(in: context, yPosition: maxYPosition - 8, pointingUp: true) + + context.restoreGState() + } + + private func drawChevron(in context: CGContext, yPosition: CGFloat, pointingUp: Bool) { + context.saveGState() + let path = CGMutablePath() + let chevronSize = CGSize(width: 4.0, height: 2.5) + + let center = (Self.width / 2) + let minX = center - (chevronSize.width / 2) + let maxX = center + (chevronSize.width / 2) + + let startY = pointingUp ? yPosition + chevronSize.height : yPosition - chevronSize.height + + context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor) + context.setLineCap(.round) + context.setLineJoin(.round) + context.setLineWidth(1.3) + + path.move(to: CGPoint(x: minX, y: startY)) + path.addLine(to: CGPoint(x: center, y: yPosition)) + path.addLine(to: CGPoint(x: maxX, y: startY)) + + context.addPath(path) + context.strokePath() + context.restoreGState() + } + + private func drawNestedFold( + markerContext: FoldMarkerDrawingContext, + minYPosition: CGFloat, + maxYPosition: CGFloat, + in context: CGContext + ) { + let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) + // TODO: Draw a single horizontal line when folds are adjacent + let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5) + + context.addPath(roundedRect.cgPathFallback) + context.drawPath(using: .fill) + + // Add small white line if we're overlapping with other markers + if markerContext.depth != 0 { + drawOutline( + minYPosition: minYPosition, + maxYPosition: maxYPosition, + originalPath: roundedRect, + in: context + ) + } + } + + /// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator. + /// + /// This function does not change fill colors for the given context. + /// + /// - Parameters: + /// - minYPosition: The minimum y position of the rectangle to outline. + /// - maxYPosition: The maximum y position of the rectangle to outline. + /// - originalPath: The original bezier path for the rounded rectangle. + /// - context: The context to draw in. + private func drawOutline( + minYPosition: CGFloat, + maxYPosition: CGFloat, + originalPath: NSBezierPath, + in context: CGContext + ) { + context.saveGState() + + let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition) + let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4) + + let combined = CGMutablePath() + combined.addPath(roundedRect.cgPathFallback) + combined.addPath(originalPath.cgPathFallback) + + context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) + context.addPath(combined) + context.setFillColor(markerBorderColor) + context.drawPath(using: .eoFill) + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift index d7d8543bf..c06c9ac03 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift @@ -8,18 +8,28 @@ import Foundation import AppKit import CodeEditTextView +import Combine #warning("Replace before release") fileprivate let demoFoldProvider = IndentationLineFoldProvider() /// Displays the code folding ribbon in the ``GutterView``. -/// -/// This view draws its contents class FoldingRibbonView: NSView { + struct HoveringFold: Equatable { + let range: ClosedRange + let depth: Int + } + static let width: CGFloat = 7.0 - private var model: LineFoldingModel - private var hoveringLine: Int? + var model: LineFoldingModel + + // Disabling this lint rule because this initial value is required for @Invalidating + @Invalidating(.display) + var hoveringFold: HoveringFold? = nil // swiftlint:disable:this redundant_optional_initialization + var hoverAnimationTimer: Timer? + @Invalidating(.display) + var hoverAnimationProgress: CGFloat = 0.0 @Invalidating(.display) var backgroundColor: NSColor = NSColor.controlBackgroundColor @@ -30,7 +40,7 @@ class FoldingRibbonView: NSView { case .aqua: NSColor(deviceWhite: 0.0, alpha: 0.1) case .darkAqua: - NSColor(deviceWhite: 1.0, alpha: 0.1) + NSColor(deviceWhite: 1.0, alpha: 0.2) default: NSColor() } @@ -48,6 +58,32 @@ class FoldingRibbonView: NSView { } }.cgColor + @Invalidating(.display) + var hoverFillColor = NSColor(name: nil) { appearance in + return switch appearance.name { + case .aqua: + NSColor(deviceWhite: 1.0, alpha: 1.0) + case .darkAqua: + NSColor(deviceWhite: 0.17, alpha: 1.0) + default: + NSColor() + } + }.cgColor + + @Invalidating(.display) + var hoverBorderColor = NSColor(name: nil) { appearance in + return switch appearance.name { + case .aqua: + NSColor(deviceWhite: 0.8, alpha: 1.0) + case .darkAqua: + NSColor(deviceWhite: 0.4, alpha: 1.0) + default: + NSColor() + } + }.cgColor + + private var foldUpdateCancellable: AnyCancellable? + override public var isFlipped: Bool { true } @@ -60,17 +96,28 @@ class FoldingRibbonView: NSView { ) super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay + clipsToBounds = false + + foldUpdateCancellable = model.foldsUpdatedPublisher.sink { + self.needsDisplay = true + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + foldUpdateCancellable?.cancel() + } + + // MARK: - Hover + override func updateTrackingAreas() { trackingAreas.forEach(removeTrackingArea) let area = NSTrackingArea( rect: bounds, - options: [.mouseMoved, .activeInKeyWindow], + options: [.mouseMoved, .activeInKeyWindow, .mouseEnteredAndExited], owner: self, userInfo: nil ) @@ -79,132 +126,45 @@ class FoldingRibbonView: NSView { override func mouseMoved(with event: NSEvent) { let pointInView = convert(event.locationInWindow, from: nil) - hoveringLine = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index - } - - /// The context in which the fold is being drawn, including the depth and fold range. - struct FoldMarkerDrawingContext { - let range: ClosedRange - let depth: UInt - - /// Increment the depth - func incrementDepth() -> FoldMarkerDrawingContext { - FoldMarkerDrawingContext( - range: range, - depth: depth + 1 - ) - } - } - - override func draw(_ dirtyRect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext, - let layoutManager = model.textView?.layoutManager else { + guard let lineNumber = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index, + let fold = model.getCachedFoldAt(lineNumber: lineNumber) else { + hoverAnimationProgress = 0.0 + hoveringFold = nil return } - context.saveGState() - context.clip(to: dirtyRect) - - // Find the visible lines in the rect AppKit is asking us to draw. - guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), - let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { + let newHoverRange = HoveringFold(range: fold.range.lineRange, depth: fold.depth) + guard newHoverRange != hoveringFold else { return } - let lineRange = rangeStart.index...rangeEnd.index - - context.setFillColor(markerColor) - let folds = model.getFolds(in: lineRange) - for fold in folds { - drawFoldMarker( - fold, - markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0), - in: context, - using: layoutManager - ) - } - - context.restoreGState() - } - - /// Draw a single fold marker for a fold. - /// - /// Ensure the correct fill color is set on the drawing context before calling. - /// - /// - Parameters: - /// - fold: The fold to draw. - /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is - /// being hovered. - /// - context: The drawing context to use. - /// - layoutManager: A layout manager used to retrieve position information for lines. - private func drawFoldMarker( - _ fold: FoldRange, - markerContext: FoldMarkerDrawingContext, - in context: CGContext, - using layoutManager: TextLayoutManager - ) { - guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos, - let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else { - return - } - - let maxYPosition = maxPosition.yPos + maxPosition.height - - if false /*model.getCachedDepthAt(lineNumber: hoveringLine ?? -1)*/ { - // TODO: Handle hover state - } else { - let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) - // TODO: Draw a single horizontal line when folds are adjacent - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5) - - context.addPath(roundedRect.cgPathFallback) - context.drawPath(using: .fill) - - // Add small white line if we're overlapping with other markers - if markerContext.depth != 0 { - drawOutline( - minYPosition: minYPosition, - maxYPosition: maxYPosition, - originalPath: roundedRect, - in: context - ) + hoverAnimationTimer?.invalidate() + // We only animate the first hovered fold. If the user moves the mouse vertically into other folds we just + // show it immediately. + if hoveringFold == nil { + hoverAnimationProgress = 0.0 + hoveringFold = newHoverRange + + let duration: TimeInterval = 0.2 + let startTime = CACurrentMediaTime() + hoverAnimationTimer = Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [weak self] timer in + guard let self = self else { return } + let now = CACurrentMediaTime() + let time = CGFloat((now - startTime) / duration) + self.hoverAnimationProgress = min(1.0, time) + if self.hoverAnimationProgress >= 1.0 { + timer.invalidate() + } } + return } - // Draw subfolds - for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { - drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) - } + // Don't animate these + hoverAnimationProgress = 1.0 + hoveringFold = newHoverRange } - /// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator. - /// - /// This function does not change fill colors for the given context. - /// - /// - Parameters: - /// - minYPosition: The minimum y position of the rectangle to outline. - /// - maxYPosition: The maximum y position of the rectangle to outline. - /// - originalPath: The original bezier path for the rounded rectangle. - /// - context: The context to draw in. - private func drawOutline( - minYPosition: CGFloat, - maxYPosition: CGFloat, - originalPath: NSBezierPath, - in context: CGContext - ) { - context.saveGState() - - let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition) - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4) - - let combined = CGMutablePath() - combined.addPath(roundedRect.cgPathFallback) - combined.addPath(originalPath.cgPathFallback) - - context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) - context.addPath(combined) - context.setFillColor(markerBorderColor) - context.drawPath(using: .eoFill) - - context.restoreGState() + override func mouseExited(with event: NSEvent) { + hoverAnimationProgress = 0.0 + hoveringFold = nil } } diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift index b2e4dfbcf..31230af35 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift @@ -7,6 +7,7 @@ import AppKit import CodeEditTextView +import Combine /// # Basic Premise /// @@ -25,6 +26,8 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { weak var foldProvider: LineFoldProvider? weak var textView: TextView? + lazy var foldsUpdatedPublisher = PassthroughSubject() + init(textView: TextView, foldProvider: LineFoldProvider?) { self.textView = textView self.foldProvider = foldProvider @@ -55,7 +58,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { ) else { continue } - print(foldDepth, linePosition.index) + // Start a new fold if foldDepth > currentDepth { let newFold = FoldRange( @@ -64,25 +67,26 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { parent: currentFold, subFolds: [] ) - if currentDepth == 0 { + + if currentFold == nil { foldCache.append(newFold) + } else { + currentFold?.subFolds.append(newFold) } - currentFold?.subFolds.append(newFold) + currentFold = newFold } else if foldDepth < currentDepth { // End this fold if let fold = currentFold { fold.lineRange = fold.lineRange.lowerBound...linePosition.index - - if foldDepth == 0 { - currentFold = nil - } } currentFold = currentFold?.parent } currentDepth = foldDepth } + + foldsUpdatedPublisher.send() } func invalidateLine(lineNumber: Int) { @@ -109,20 +113,20 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate { /// - Parameter lineNumber: The line number to query, zero-indexed. /// - Returns: The deepest cached depth of the fold if it was found. func getCachedDepthAt(lineNumber: Int) -> Int? { - return findCachedFoldAt(lineNumber: lineNumber)?.depth + return getCachedFoldAt(lineNumber: lineNumber)?.depth } -} -// MARK: - Search Folds - -private extension LineFoldingModel { /// Finds the deepest cached fold and depth of the fold for a line number. /// - Parameter lineNumber: The line number to query, zero-indexed. /// - Returns: The deepest cached fold and depth of the fold if it was found. - func findCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? { + func getCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? { binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0) } +} +// MARK: - Search Folds + +private extension LineFoldingModel { /// A generic function for searching an ordered array of fold ranges. /// - Returns: The found range and depth it was found at, if it exists. func binarySearchFoldsArray( From ea470165b1fd90edea889c30e1612c58352996fb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:43:49 -0500 Subject: [PATCH 03/14] Async and Faster Folding Calculation (#331) ### Description Updates line folding to happen asynchronously off the main thread, and to work while editing text. It now remembers folded ranges and correctly handles nested folds. > Sorry for this huge commit log, it's terrible! Thankfully it'll be squashed when merged. The meat of the changes here are in `LineFoldCalculator`, `LineFoldModel`, and `LineFoldStorage`. I've moved some files, resulting in the large diff and I'm sorry for that for reviewers I know that makes it hard. - Refactors the folding model to use a new `LineFoldCalculator` type. - This type accepts an async stream of edit notifications, and produces a stream of the new `LineFoldStorage` type. - Asynchronously accesses text on the main thread for safety. - Adds a new `LineFoldStorage` type. - Internally uses the `RangeStore` type to quickly store fold ranges as spans in a text document. - Has methods for querying text ranges, collapsing ranges, and updating using new values from the `LineFoldCalculator` stream. - Is `Sendable` to work easily with async streams. - Updates the drawing code to handle new behaviors of the fold model. ### Related Issues * #43 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/bc1d5bd1-bf87-45ba-ad0f-53655b2542fc --------- Co-authored-by: Austin Condiff --- .../xcshareddata/swiftpm/Package.resolved | 9 - Package.resolved | 9 - Package.swift | 5 +- .../TextViewController+Lifecycle.swift | 3 +- .../DispatchQueue+dispatchMainIfNot.swift | 6 +- .../NSBezierPath+RoundedCorners.swift | 111 +++++++ .../NSEdgeInsets+Equatable.swift | 0 .../NSEdgeInsets+Helpers.swift | 0 .../{ => NSFont}/NSFont+LineHeight.swift | 0 .../{ => NSFont}/NSFont+RulerFont.swift | 0 .../Extensions/NSString+TextStory.swift | 19 ++ .../TextView+/TextView+createReadBlock.swift | 4 +- .../Gutter/GutterView.swift | 8 +- .../IndentationLineFoldProvider.swift | 34 -- .../Gutter/LineFolding/FoldRange.swift | 25 -- .../LineFolding/FoldingRibbonView+Draw.swift | 205 ------------ .../Gutter/LineFolding/LineFoldProvider.swift | 13 - .../Gutter/LineFolding/LineFoldingModel.swift | 159 ---------- .../Highlighting/Highlighter.swift | 18 +- .../StyledRangeContainer.swift | 4 +- .../IndentationLineFoldProvider.swift | 64 ++++ .../FoldProviders/LineFoldProvider.swift | 42 +++ .../Model/LineFoldCalculator.swift | 184 +++++++++++ .../LineFolding/Model/LineFoldStorage.swift | 123 ++++++++ .../LineFolding/Model/LineFoldingModel.swift | 95 ++++++ .../Placeholder/LineFoldPlaceholder.swift | 33 ++ .../View/FoldingRibbonView+Draw.swift | 298 ++++++++++++++++++ .../View/FoldingRibbonView+FoldCapInfo.swift | 50 +++ .../View}/FoldingRibbonView.swift | 94 ++++-- .../RangeStore/RangeStore+FindIndex.swift | 7 + .../RangeStore/RangeStore.swift | 19 +- .../TreeSitter/Atomic.swift | 4 + .../TreeSitter/LanguageLayer.swift | 2 +- .../FindPanelViewModelTests.swift | 42 --- .../LineFoldStorageTests.swift | 53 ++++ .../LineFoldingModelTests.swift | 46 ++- 36 files changed, 1215 insertions(+), 573 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift rename Sources/CodeEditSourceEditor/Extensions/{ => NSEdgeInsets}/NSEdgeInsets+Equatable.swift (100%) rename Sources/CodeEditSourceEditor/Extensions/{ => NSEdgeInsets}/NSEdgeInsets+Helpers.swift (100%) rename Sources/CodeEditSourceEditor/Extensions/{ => NSFont}/NSFont+LineHeight.swift (100%) rename Sources/CodeEditSourceEditor/Extensions/{ => NSFont}/NSFont+RulerFont.swift (100%) create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift delete mode 100644 Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift create mode 100644 Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift rename Sources/CodeEditSourceEditor/{Gutter/LineFolding => LineFolding/View}/FoldingRibbonView.swift (60%) delete mode 100644 Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift create mode 100644 Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 243527a2a..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" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index 7296d78dd..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" : "69282e2ea7ad8976b062b945d575da47b61ed208", - "version" : "0.11.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 69556c288..1129c9d35 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.1" + path: "../CodeEditTextView" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.11.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index c49e4afe3..37497a49e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -26,7 +26,7 @@ extension TextViewController { font: font.rulerFont, textColor: theme.text.color.withAlphaComponent(0.35), selectedTextColor: theme.text.color, - textView: textView, + controller: self, delegate: self ) gutterView.updateWidthIfNeeded() @@ -143,6 +143,7 @@ extension TextViewController { - (self?.scrollView.contentInsets.top ?? 0) self?.gutterView.needsDisplay = true + self?.gutterView.foldingRibbon.needsDisplay = true self?.guideView?.updatePosition(in: textView) self?.scrollView.needsLayout = true } diff --git a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift index 0b14ea65a..b579e8d52 100644 --- a/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift +++ b/Sources/CodeEditSourceEditor/Extensions/DispatchQueue+dispatchMainIfNot.swift @@ -27,13 +27,11 @@ extension DispatchQueue { /// executed if not already on the main thread. /// - Parameter item: The work item to execute. /// - Returns: The value of the work item. - static func syncMainIfNot(_ item: @escaping () -> T) -> T { + static func waitMainIfNot(_ item: () -> T) -> T { if Thread.isMainThread { return item() } else { - return DispatchQueue.main.sync { - return item() - } + return DispatchQueue.main.asyncAndWait(execute: item) } } } diff --git a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift new file mode 100644 index 000000000..09156caea --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift @@ -0,0 +1,111 @@ +// +// NSBezierPath+RoundedCorners.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/3/25. +// + +import AppKit + +// Wonderful NSBezierPath extension taken with modification from the playground code at: +// https://github.com/janheiermann/BezierPath-Corners + +extension NSBezierPath { + struct Corners: OptionSet { + public let rawValue: Int + + public init(rawValue: Corners.RawValue) { + self.rawValue = rawValue + } + + public static let topLeft = Corners(rawValue: 1 << 0) + public static let bottomLeft = Corners(rawValue: 1 << 1) + public static let topRight = Corners(rawValue: 1 << 2) + public static let bottomRight = Corners(rawValue: 1 << 3) + } + + // swiftlint:disable:next function_body_length + convenience init(rect: CGRect, roundedCorners corners: Corners, cornerRadius: CGFloat) { + self.init() + + let maxX = rect.maxX + let minX = rect.minX + let maxY = rect.maxY + let minY = rect.minY + let radius = min(cornerRadius, min(rect.width, rect.height) / 2) + + // Start at bottom-left corner + move(to: CGPoint(x: minX + (corners.contains(.bottomLeft) ? radius : 0), y: minY)) + + // Bottom edge + if corners.contains(.bottomRight) { + line(to: CGPoint(x: maxX - radius, y: minY)) + appendArc( + withCenter: CGPoint(x: maxX - radius, y: minY + radius), + radius: radius, + startAngle: 270, + endAngle: 0, + clockwise: false + ) + } else { + line(to: CGPoint(x: maxX, y: minY)) + } + + // Right edge + if corners.contains(.topRight) { + line(to: CGPoint(x: maxX, y: maxY - radius)) + appendArc( + withCenter: CGPoint(x: maxX - radius, y: maxY - radius), + radius: radius, + startAngle: 0, + endAngle: 90, + clockwise: false + ) + } else { + line(to: CGPoint(x: maxX, y: maxY)) + } + + // Top edge + if corners.contains(.topLeft) { + line(to: CGPoint(x: minX + radius, y: maxY)) + appendArc( + withCenter: CGPoint(x: minX + radius, y: maxY - radius), + radius: radius, + startAngle: 90, + endAngle: 180, + clockwise: false + ) + } else { + line(to: CGPoint(x: minX, y: maxY)) + } + + // Left edge + if corners.contains(.bottomLeft) { + line(to: CGPoint(x: minX, y: minY + radius)) + appendArc( + withCenter: CGPoint(x: minX + radius, y: minY + radius), + radius: radius, + startAngle: 180, + endAngle: 270, + clockwise: false + ) + } else { + line(to: CGPoint(x: minX, y: minY)) + } + + close() + } + + convenience init(roundingRect: CGRect, capTop: Bool, capBottom: Bool, cornerRadius radius: CGFloat) { + switch (capTop, capBottom) { + case (true, true): + self.init(rect: roundingRect) + case (false, true): + self.init(rect: roundingRect, roundedCorners: [.bottomLeft, .bottomRight], cornerRadius: radius) + case (true, false): + self.init(rect: roundingRect, roundedCorners: [.topLeft, .topRight], cornerRadius: radius) + case (false, false): + self.init(roundedRect: roundingRect, xRadius: radius, yRadius: radius) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Equatable.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Equatable.swift rename to Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Equatable.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Helpers.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift rename to Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets/NSEdgeInsets+Helpers.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+LineHeight.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSFont+LineHeight.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+LineHeight.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+RulerFont.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Extensions/NSFont+RulerFont.swift rename to Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+RulerFont.swift diff --git a/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift b/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift new file mode 100644 index 000000000..870e11545 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSString+TextStory.swift @@ -0,0 +1,19 @@ +// +// NSString+TextStory.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/3/25. +// + +import AppKit +import TextStory + +extension NSString: @retroactive TextStoring { + public func substring(from range: NSRange) -> String? { + self.substring(with: range) + } + + public func applyMutation(_ mutation: TextMutation) { + self.replacingCharacters(in: mutation.range, with: mutation.string) + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift index 38153e154..2e00dff36 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift @@ -30,7 +30,7 @@ extension TextView { let range = NSRange(location.. String? = { self?.textStorage.substring(from: range) } - return DispatchQueue.syncMainIfNot(workItem) + return DispatchQueue.waitMainIfNot(workItem) } } } diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 2a5125789..76d9152ee 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -101,7 +101,7 @@ public class GutterView: NSView { } /// The view that draws the fold decoration in the gutter. - private var foldingRibbon: FoldingRibbonView + var foldingRibbon: FoldingRibbonView /// Syntax helper for determining the required space for the folding ribbon. private var foldingRibbonWidth: CGFloat { @@ -137,16 +137,16 @@ public class GutterView: NSView { font: NSFont, textColor: NSColor, selectedTextColor: NSColor?, - textView: TextView, + controller: TextViewController, delegate: GutterViewDelegate? = nil ) { self.font = font self.textColor = textColor self.selectedLineTextColor = selectedTextColor ?? .secondaryLabelColor - self.textView = textView + self.textView = controller.textView self.delegate = delegate - foldingRibbon = FoldingRibbonView(textView: textView, foldProvider: nil) + foldingRibbon = FoldingRibbonView(controller: controller, foldProvider: nil) super.init(frame: .zero) clipsToBounds = true diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift deleted file mode 100644 index 03dbd9fa1..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/DefaultProviders/IndentationLineFoldProvider.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// IndentationLineFoldProvider.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/8/25. -// - -import AppKit -import CodeEditTextView - -final class IndentationLineFoldProvider: LineFoldProvider { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { - guard let linePosition = layoutManager.textLineForIndex(lineNumber), - let indentLevel = indentLevelForPosition(linePosition, textStorage: textStorage) else { - return nil - } - - return indentLevel - } - - private func indentLevelForPosition( - _ position: TextLineStorage.TextLinePosition, - textStorage: NSTextStorage - ) -> Int? { - guard let substring = textStorage.substring(from: position.range) else { - return nil - } - - return substring.utf16 // Keep NSString units - .enumerated() - .first(where: { UnicodeScalar($0.element)?.properties.isWhitespace != true })? - .offset - } -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift deleted file mode 100644 index 714a48a06..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldRange.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FoldRange.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import Foundation - -/// Represents a recursive folded range -class FoldRange { - var lineRange: ClosedRange - var range: NSRange - /// Ordered array of ranges that are nested in this fold. - var subFolds: [FoldRange] - - weak var parent: FoldRange? - - init(lineRange: ClosedRange, range: NSRange, parent: FoldRange?, subFolds: [FoldRange]) { - self.lineRange = lineRange - self.range = range - self.subFolds = subFolds - self.parent = parent - } -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift deleted file mode 100644 index d85a25916..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView+Draw.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// FoldingRibbonView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/8/25. -// - -import AppKit -import CodeEditTextView - -extension FoldingRibbonView { - /// The context in which the fold is being drawn, including the depth and fold range. - struct FoldMarkerDrawingContext { - let range: ClosedRange - let depth: UInt - - /// Increment the depth - func incrementDepth() -> FoldMarkerDrawingContext { - FoldMarkerDrawingContext( - range: range, - depth: depth + 1 - ) - } - } - - override func draw(_ dirtyRect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext, - let layoutManager = model.textView?.layoutManager else { - return - } - - context.saveGState() - context.clip(to: dirtyRect) - - // Find the visible lines in the rect AppKit is asking us to draw. - guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), - let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { - return - } - let lineRange = rangeStart.index...rangeEnd.index - - context.setFillColor(markerColor) - let folds = model.getFolds(in: lineRange) - for fold in folds { - drawFoldMarker( - fold, - markerContext: FoldMarkerDrawingContext(range: lineRange, depth: 0), - in: context, - using: layoutManager - ) - } - - context.restoreGState() - } - - /// Draw a single fold marker for a fold. - /// - /// Ensure the correct fill color is set on the drawing context before calling. - /// - /// - Parameters: - /// - fold: The fold to draw. - /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is - /// being hovered. - /// - context: The drawing context to use. - /// - layoutManager: A layout manager used to retrieve position information for lines. - private func drawFoldMarker( - _ fold: FoldRange, - markerContext: FoldMarkerDrawingContext, - in context: CGContext, - using layoutManager: TextLayoutManager - ) { - guard let minYPosition = layoutManager.textLineForIndex(fold.lineRange.lowerBound)?.yPos, - let maxPosition = layoutManager.textLineForIndex(fold.lineRange.upperBound) else { - return - } - - let maxYPosition = maxPosition.yPos + maxPosition.height - - if let hoveringFold, - hoveringFold.depth == markerContext.depth, - fold.lineRange == hoveringFold.range { - drawHoveredFold( - markerContext: markerContext, - minYPosition: minYPosition, - maxYPosition: maxYPosition, - in: context - ) - } else { - drawNestedFold( - markerContext: markerContext, - minYPosition: minYPosition, - maxYPosition: maxYPosition, - in: context - ) - } - - // Draw subfolds - for subFold in fold.subFolds.filter({ $0.lineRange.overlaps(markerContext.range) }) { - drawFoldMarker(subFold, markerContext: markerContext.incrementDepth(), in: context, using: layoutManager) - } - } - - private func drawHoveredFold( - markerContext: FoldMarkerDrawingContext, - minYPosition: CGFloat, - maxYPosition: CGFloat, - in context: CGContext - ) { - context.saveGState() - let plainRect = NSRect(x: -2, y: minYPosition, width: 11.0, height: maxYPosition - minYPosition) - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 11.0 / 2, yRadius: 11.0 / 2) - - context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor) - context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? hoverBorderColor) - context.addPath(roundedRect.cgPathFallback) - context.drawPath(using: .fillStroke) - - // Add the little arrows - drawChevron(in: context, yPosition: minYPosition + 8, pointingUp: false) - drawChevron(in: context, yPosition: maxYPosition - 8, pointingUp: true) - - context.restoreGState() - } - - private func drawChevron(in context: CGContext, yPosition: CGFloat, pointingUp: Bool) { - context.saveGState() - let path = CGMutablePath() - let chevronSize = CGSize(width: 4.0, height: 2.5) - - let center = (Self.width / 2) - let minX = center - (chevronSize.width / 2) - let maxX = center + (chevronSize.width / 2) - - let startY = pointingUp ? yPosition + chevronSize.height : yPosition - chevronSize.height - - context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor) - context.setLineCap(.round) - context.setLineJoin(.round) - context.setLineWidth(1.3) - - path.move(to: CGPoint(x: minX, y: startY)) - path.addLine(to: CGPoint(x: center, y: yPosition)) - path.addLine(to: CGPoint(x: maxX, y: startY)) - - context.addPath(path) - context.strokePath() - context.restoreGState() - } - - private func drawNestedFold( - markerContext: FoldMarkerDrawingContext, - minYPosition: CGFloat, - maxYPosition: CGFloat, - in context: CGContext - ) { - let plainRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) - // TODO: Draw a single horizontal line when folds are adjacent - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 3.5, yRadius: 3.5) - - context.addPath(roundedRect.cgPathFallback) - context.drawPath(using: .fill) - - // Add small white line if we're overlapping with other markers - if markerContext.depth != 0 { - drawOutline( - minYPosition: minYPosition, - maxYPosition: maxYPosition, - originalPath: roundedRect, - in: context - ) - } - } - - /// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator. - /// - /// This function does not change fill colors for the given context. - /// - /// - Parameters: - /// - minYPosition: The minimum y position of the rectangle to outline. - /// - maxYPosition: The maximum y position of the rectangle to outline. - /// - originalPath: The original bezier path for the rounded rectangle. - /// - context: The context to draw in. - private func drawOutline( - minYPosition: CGFloat, - maxYPosition: CGFloat, - originalPath: NSBezierPath, - in context: CGContext - ) { - context.saveGState() - - let plainRect = NSRect(x: -0.5, y: minYPosition, width: 8, height: maxYPosition - minYPosition) - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 4, yRadius: 4) - - let combined = CGMutablePath() - combined.addPath(roundedRect.cgPathFallback) - combined.addPath(originalPath.cgPathFallback) - - context.clip(to: CGRect(x: 0, y: minYPosition, width: 7, height: maxYPosition - minYPosition)) - context.addPath(combined) - context.setFillColor(markerBorderColor) - context.drawPath(using: .eoFill) - - context.restoreGState() - } -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift deleted file mode 100644 index 64a15ae71..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldProvider.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// LineFoldProvider.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import AppKit -import CodeEditTextView - -protocol LineFoldProvider: AnyObject { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? -} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift deleted file mode 100644 index 31230af35..000000000 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/LineFoldingModel.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// LineFoldingModel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 5/7/25. -// - -import AppKit -import CodeEditTextView -import Combine - -/// # Basic Premise -/// -/// We need to update, delete, or add fold ranges in the invalidated lines. -/// -/// # Implementation -/// -/// - For each line in the document, put its indent level into a list. -/// - Loop through the list, creating nested folds as indents go up and down. -/// -class LineFoldingModel: NSObject, NSTextStorageDelegate { - /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` - /// and ``FoldRange/subFolds``. - private var foldCache: [FoldRange] = [] - - weak var foldProvider: LineFoldProvider? - weak var textView: TextView? - - lazy var foldsUpdatedPublisher = PassthroughSubject() - - init(textView: TextView, foldProvider: LineFoldProvider?) { - self.textView = textView - self.foldProvider = foldProvider - super.init() - textView.addStorageDelegate(self) - buildFoldsForDocument() - } - - func getFolds(in lineRange: ClosedRange) -> [FoldRange] { - foldCache.filter({ $0.lineRange.overlaps(lineRange) }) - } - - /// Build out the ``foldCache`` for the entire document. - /// - /// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the - /// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in. - func buildFoldsForDocument() { - guard let textView, let foldProvider else { return } - foldCache.removeAll(keepingCapacity: true) - - var currentFold: FoldRange? - var currentDepth: Int = 0 - for linePosition in textView.layoutManager.linesInRange(textView.documentRange) { - guard let foldDepth = foldProvider.foldLevelAtLine( - linePosition.index, - layoutManager: textView.layoutManager, - textStorage: textView.textStorage - ) else { - continue - } - - // Start a new fold - if foldDepth > currentDepth { - let newFold = FoldRange( - lineRange: (linePosition.index - 1)...(linePosition.index - 1), - range: .zero, - parent: currentFold, - subFolds: [] - ) - - if currentFold == nil { - foldCache.append(newFold) - } else { - currentFold?.subFolds.append(newFold) - } - - currentFold = newFold - } else if foldDepth < currentDepth { - // End this fold - if let fold = currentFold { - fold.lineRange = fold.lineRange.lowerBound...linePosition.index - } - currentFold = currentFold?.parent - } - - currentDepth = foldDepth - } - - foldsUpdatedPublisher.send() - } - - func invalidateLine(lineNumber: Int) { - // TODO: Check if we need to rebuild, or even better, incrementally update the tree. - - // Temporary - buildFoldsForDocument() - } - - func textStorage( - _ textStorage: NSTextStorage, - didProcessEditing editedMask: NSTextStorageEditActions, - range editedRange: NSRange, - changeInLength delta: Int - ) { - guard editedMask.contains(.editedCharacters), - let lineNumber = textView?.layoutManager.textLineForOffset(editedRange.location)?.index else { - return - } - invalidateLine(lineNumber: lineNumber) - } - - /// Finds the deepest cached depth of the fold for a line number. - /// - Parameter lineNumber: The line number to query, zero-indexed. - /// - Returns: The deepest cached depth of the fold if it was found. - func getCachedDepthAt(lineNumber: Int) -> Int? { - return getCachedFoldAt(lineNumber: lineNumber)?.depth - } - - /// Finds the deepest cached fold and depth of the fold for a line number. - /// - Parameter lineNumber: The line number to query, zero-indexed. - /// - Returns: The deepest cached fold and depth of the fold if it was found. - func getCachedFoldAt(lineNumber: Int) -> (range: FoldRange, depth: Int)? { - binarySearchFoldsArray(lineNumber: lineNumber, folds: foldCache, currentDepth: 0) - } -} - -// MARK: - Search Folds - -private extension LineFoldingModel { - /// A generic function for searching an ordered array of fold ranges. - /// - Returns: The found range and depth it was found at, if it exists. - func binarySearchFoldsArray( - lineNumber: Int, - folds: borrowing [FoldRange], - currentDepth: Int - ) -> (range: FoldRange, depth: Int)? { - var low = 0 - var high = folds.count - 1 - - while low <= high { - let mid = (low + high) / 2 - let fold = folds[mid] - - if fold.lineRange.contains(lineNumber) { - // Search deeper into subFolds, if any - return binarySearchFoldsArray( - lineNumber: lineNumber, - folds: fold.subFolds, - currentDepth: currentDepth + 1 - ) ?? (fold, currentDepth) - } else if lineNumber < fold.lineRange.lowerBound { - high = mid - 1 - } else { - low = mid + 1 - } - } - return nil - } -} diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index 678e2761f..50feee21f 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -223,23 +223,9 @@ extension Highlighter: NSTextStorageDelegate { ) { // This method is called whenever attributes are updated, so to avoid re-highlighting the entire document // each time an attribute is applied, we check to make sure this is in response to an edit. - guard editedMask.contains(.editedCharacters), let textView else { return } - - let styleContainerRange: Range - let newLength: Int - - if editedRange.length == 0 { // Deleting, editedRange is at beginning of the range that was deleted - styleContainerRange = editedRange.location..<(editedRange.location - delta) - newLength = 0 - } else { // Replacing or inserting - styleContainerRange = editedRange.location..<(editedRange.location + editedRange.length - delta) - newLength = editedRange.length - } + guard editedMask.contains(.editedCharacters) else { return } - styleContainer.storageUpdated( - replacedContentIn: styleContainerRange, - withCount: newLength - ) + styleContainer.storageUpdated(editedRange: editedRange, changeInLength: delta) if delta > 0 { visibleRangeProvider.visibleSet.insert(range: editedRange) diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index d61bfb009..95c81cb7e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -127,9 +127,9 @@ class StyledRangeContainer { return runs.reversed() } - func storageUpdated(replacedContentIn range: Range, withCount newLength: Int) { + func storageUpdated(editedRange: NSRange, changeInLength delta: Int) { for key in _storage.keys { - _storage[key]?.storageUpdated(replacedCharactersIn: range, withCount: newLength) + _storage[key]?.storageUpdated(editedRange: editedRange, changeInLength: delta) } } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift new file mode 100644 index 000000000..84626c971 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift @@ -0,0 +1,64 @@ +// +// IndentationLineFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +final class IndentationLineFoldProvider: LineFoldProvider { + func indentLevelAtLine(substring: NSString) -> Int? { + for idx in 0.. [LineFoldProviderLineInfo] { + let text = controller.textView.textStorage.string as NSString + guard let leadingIndent = text.leadingRange(in: lineRange, within: .whitespacesAndNewlines)?.length, + leadingIndent != lineRange.length else { + return [] + } + var foldIndicators: [LineFoldProviderLineInfo] = [] + + let leadingDepth = leadingIndent / controller.indentOption.charCount + if leadingDepth < previousDepth { + // End the fold at the start of whitespace + foldIndicators.append( + .endFold( + rangeEnd: lineRange.location + leadingIndent, + newDepth: leadingDepth + ) + ) + } + + // Check if the next line has more indent + let maxRange = NSRange(start: lineRange.max, end: text.length) + guard let nextIndent = text.leadingRange(in: maxRange, within: .whitespacesWithoutNewlines)?.length, + nextIndent > 0 else { + return foldIndicators + } + + if nextIndent > leadingIndent, let trailingWhitespace = text.trailingWhitespaceRange(in: lineRange) { + foldIndicators.append( + .startFold( + rangeStart: trailingWhitespace.location, + newDepth: nextIndent / controller.indentOption.charCount + ) + ) + } + + return foldIndicators + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift new file mode 100644 index 000000000..2e829209f --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift @@ -0,0 +1,42 @@ +// +// LineFoldProvider.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView + +enum LineFoldProviderLineInfo { + case startFold(rangeStart: Int, newDepth: Int) + case endFold(rangeEnd: Int, newDepth: Int) + + var depth: Int { + switch self { + case .startFold(_, let newDepth): + return newDepth + case .endFold(_, let newDepth): + return newDepth + } + } + + var rangeIndice: Int { + switch self { + case .startFold(let rangeStart, _): + return rangeStart + case .endFold(let rangeEnd, _): + return rangeEnd + } + } +} + +@MainActor +protocol LineFoldProvider: AnyObject { + func foldLevelAtLine( + lineNumber: Int, + lineRange: NSRange, + previousDepth: Int, + controller: TextViewController + ) -> [LineFoldProviderLineInfo] +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift new file mode 100644 index 000000000..3a12022d0 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -0,0 +1,184 @@ +// +// LineFoldCalculator.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView + +/// A utility that calculates foldable line ranges in a text document based on indentation depth. +/// +/// `LineFoldCalculator` observes text edits and rebuilds fold regions asynchronously. +actor LineFoldCalculator { + weak var foldProvider: LineFoldProvider? + weak var controller: TextViewController? + + var valueStream: AsyncStream + + private var valueStreamContinuation: AsyncStream.Continuation + private var textChangedTask: Task? + + /// Create a new calculator object that listens to a given stream for text changes. + /// - Parameters: + /// - foldProvider: The object to use to calculate fold regions. + /// - controller: The text controller to use for text and attachment fetching. + /// - textChangedStream: A stream of text changes, received as the document is edited. + init( + foldProvider: LineFoldProvider?, + controller: TextViewController, + textChangedStream: AsyncStream<(NSRange, Int)> + ) { + self.foldProvider = foldProvider + self.controller = controller + (valueStream, valueStreamContinuation) = AsyncStream.makeStream() + Task { await listenToTextChanges(textChangedStream: textChangedStream) } + } + + deinit { + textChangedTask?.cancel() + } + + /// Sets up an attached task to listen to values on a stream of text changes. + /// - Parameter textChangedStream: A stream of text changes. + private func listenToTextChanges(textChangedStream: AsyncStream<(NSRange, Int)>) { + textChangedTask = Task { + for await edit in textChangedStream { + await buildFoldsForDocument(afterEditIn: edit.0, delta: edit.1) + } + } + } + + /// Build out the folds for the entire document. + /// + /// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the + /// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in. + private func buildFoldsForDocument(afterEditIn: NSRange, delta: Int) async { + guard let controller = self.controller, let foldProvider = self.foldProvider else { return } + let documentRange = await controller.textView.documentRange + var foldCache: [LineFoldStorage.RawFold] = [] + // Depth: Open range + var openFolds: [Int: LineFoldStorage.RawFold] = [:] + var currentDepth: Int = 0 + var lineIterator = await ChunkedLineIterator( + controller: controller, + foldProvider: foldProvider, + textIterator: await controller.textView.layoutManager.lineStorage.makeIterator() + ) + + for await lineChunk in lineIterator { + for lineInfo in lineChunk { + // Start a new fold, going deeper to a new depth. + if lineInfo.depth > currentDepth { + let newFold = LineFoldStorage.RawFold( + depth: lineInfo.depth, + range: lineInfo.rangeIndice.. received depth + for openFold in openFolds.values.filter({ $0.depth > lineInfo.depth }) { + openFolds.removeValue(forKey: openFold.depth) + foldCache.append( + LineFoldStorage.RawFold( + depth: openFold.depth, + range: openFold.range.lowerBound.. LineFoldStorage.DepthStartPair? in + guard let attachment = attachmentBox.attachment as? LineFoldPlaceholder else { + return nil + } + return LineFoldStorage.DepthStartPair(depth: attachment.fold.depth, start: attachmentBox.range.location) + } + + let storage = LineFoldStorage( + documentLength: newFolds.max( + by: { $0.range.upperBound < $1.range.upperBound } + )?.range.upperBound ?? documentRange.length, + folds: newFolds.sorted(by: { $0.range.lowerBound < $1.range.lowerBound }), + collapsedRanges: Set(attachments) + ) + valueStreamContinuation.yield(storage) + } + + /// Asynchronously gets more line information from the fold provider. + /// Runs on the main thread so all text-related calculations are safe with the main text storage. + /// + /// Has to be an `AsyncSequence` so it can be main actor isolated. + @MainActor + struct ChunkedLineIterator: AsyncSequence, AsyncIteratorProtocol { + var controller: TextViewController + var foldProvider: LineFoldProvider + private var previousDepth: Int = 0 + var textIterator: TextLineStorage.TextLineStorageIterator + + init( + controller: TextViewController, + foldProvider: LineFoldProvider, + textIterator: TextLineStorage.TextLineStorageIterator + ) { + self.controller = controller + self.foldProvider = foldProvider + self.textIterator = textIterator + } + + nonisolated func makeAsyncIterator() -> ChunkedLineIterator { + self + } + + mutating func next() -> [LineFoldProviderLineInfo]? { + var results: [LineFoldProviderLineInfo] = [] + var count = 0 + var previousDepth: Int = previousDepth + while count < 50, let linePosition = textIterator.next() { + let foldInfo = foldProvider.foldLevelAtLine( + lineNumber: linePosition.index, + lineRange: linePosition.range, + previousDepth: previousDepth, + controller: controller + ) + results.append(contentsOf: foldInfo) + count += 1 + previousDepth = foldInfo.max(by: { $0.depth < $1.depth })?.depth ?? previousDepth + } + if results.isEmpty && count == 0 { + return nil + } + return results + } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift new file mode 100644 index 000000000..52b5d78dd --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -0,0 +1,123 @@ +// +// LineFoldStorage.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// +import _RopeModule +import Foundation + +/// Represents a single fold region with stable identifier and collapse state +struct FoldRange: Sendable, Equatable { + typealias FoldIdentifier = UInt32 + + let id: FoldIdentifier + let depth: Int + let range: Range + var isCollapsed: Bool + + func isHoveringEqual(_ other: FoldRange) -> Bool { + depth == other.depth && range.contains(other.range) + } +} + +/// Sendable data model for code folding using RangeStore +struct LineFoldStorage: Sendable { + /// A temporary fold representation without stable ID + struct RawFold: Sendable { + let depth: Int + let range: Range + } + + struct DepthStartPair: Hashable { + let depth: Int + let start: Int + } + + /// Element stored in RangeStore: holds reference to a fold region + struct FoldStoreElement: RangeStoreElement, Sendable { + let id: FoldRange.FoldIdentifier + let depth: Int + + var isEmpty: Bool { false } + } + + private var idCounter = FoldRange.FoldIdentifier.zero + private var store: RangeStore + private var foldRanges: [FoldRange.FoldIdentifier: FoldRange] = [:] + + /// Initialize with the full document length + init(documentLength: Int, folds: [RawFold] = [], collapsedRanges: Set = []) { + self.store = RangeStore(documentLength: documentLength) + self.updateFolds(from: folds, collapsedRanges: collapsedRanges) + } + + private mutating func nextFoldId() -> FoldRange.FoldIdentifier { + idCounter += 1 + return idCounter + } + + /// Replace all fold data from raw folds, preserving collapse state via callback + /// - Parameter rawFolds: newly computed folds (depth + range) + /// - Parameter collapsedRanges: Current collapsed ranges/depths + mutating func updateFolds(from rawFolds: [RawFold], collapsedRanges: Set) { + // Build reuse map by start+depth, carry over collapse state + var reuseMap: [DepthStartPair: FoldRange] = [:] + for region in foldRanges.values { + reuseMap[DepthStartPair(depth: region.depth, start: region.range.lowerBound)] = region + } + + // Build new regions + foldRanges.removeAll(keepingCapacity: true) + store = RangeStore(documentLength: store.length) + + for raw in rawFolds { + let key = DepthStartPair(depth: raw.depth, start: raw.range.lowerBound) + // reuse id and collapse state if available + let prior = reuseMap[key] + let id = prior?.id ?? nextFoldId() + let wasCollapsed = prior?.isCollapsed ?? false + // override collapse if provider says so + let isCollapsed = collapsedRanges.contains(key) || wasCollapsed + let fold = FoldRange(id: id, depth: raw.depth, range: raw.range, isCollapsed: isCollapsed) + + foldRanges[id] = fold + let elem = FoldStoreElement(id: id, depth: raw.depth) + store.set(value: elem, for: raw.range) + } + } + + /// Keep folding offsets in sync after text edits + mutating func storageUpdated(editedRange: NSRange, changeInLength delta: Int) { + store.storageUpdated(editedRange: editedRange, changeInLength: delta) + } + + mutating func toggleCollapse(forFold fold: FoldRange) { + guard var existingRange = foldRanges[fold.id] else { return } + existingRange.isCollapsed.toggle() + foldRanges[fold.id] = existingRange + } + + /// Query a document subrange and return all folds as an ordered list by start position + func folds(in queryRange: Range) -> [FoldRange] { + let runs = store.runs(in: queryRange.clamped(to: 0.. = [] + var result: [FoldRange] = [] + + for run in runs { + if let elem = run.value, !alreadyReturnedIDs.contains(elem.id), let range = foldRanges[elem.id] { + result.append( + FoldRange( + id: elem.id, + depth: elem.depth, + range: range.range, + isCollapsed: range.isCollapsed + ) + ) + alreadyReturnedIDs.insert(elem.id) + } + } + + return result.sorted { $0.range.lowerBound < $1.range.lowerBound } + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift new file mode 100644 index 000000000..08137c418 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -0,0 +1,95 @@ +// +// LineFoldingModel.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/7/25. +// + +import AppKit +import CodeEditTextView +import Combine + +/// # Basic Premise +/// +/// We need to update, delete, or add fold ranges in the invalidated lines. +/// +/// # Implementation +/// +/// - For each line in the document, put its indent level into a list. +/// - Loop through the list, creating nested folds as indents go up and down. +/// +class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { + /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` + /// and ``FoldRange/subFolds``. + @Published var foldCache: LineFoldStorage = LineFoldStorage(documentLength: 0) + private var calculator: LineFoldCalculator + + private var textChangedStream: AsyncStream<(NSRange, Int)> + private var textChangedStreamContinuation: AsyncStream<(NSRange, Int)>.Continuation + private var cacheListenTask: Task? + + weak var controller: TextViewController? + + init(controller: TextViewController, foldView: NSView, foldProvider: LineFoldProvider?) { + self.controller = controller + (textChangedStream, textChangedStreamContinuation) = AsyncStream<(NSRange, Int)>.makeStream() + self.calculator = LineFoldCalculator( + foldProvider: foldProvider, + controller: controller, + textChangedStream: textChangedStream + ) + super.init() + controller.textView.addStorageDelegate(self) + + cacheListenTask = Task { @MainActor [weak foldView] in + for await newFolds in await calculator.valueStream { + foldCache = newFolds + foldView?.needsDisplay = true + } + } + textChangedStreamContinuation.yield((.zero, 0)) + } + + func getFolds(in range: Range) -> [FoldRange] { + foldCache.folds(in: range) + } + + func textStorage( + _ textStorage: NSTextStorage, + didProcessEditing editedMask: NSTextStorageEditActions, + range editedRange: NSRange, + changeInLength delta: Int + ) { + guard editedMask.contains(.editedCharacters) else { + return + } + foldCache.storageUpdated(editedRange: editedRange, changeInLength: delta) + textChangedStreamContinuation.yield((editedRange, delta)) + } + + /// Finds the deepest cached depth of the fold for a line number. + /// - Parameter lineNumber: The line number to query, zero-indexed. + /// - Returns: The deepest cached depth of the fold if it was found. + func getCachedDepthAt(lineNumber: Int) -> Int? { + return getCachedFoldAt(lineNumber: lineNumber)?.depth + } + + /// Finds the deepest cached fold and depth of the fold for a line number. + /// - Parameter lineNumber: The line number to query, zero-indexed. + /// - Returns: The deepest cached fold and depth of the fold if it was found. + func getCachedFoldAt(lineNumber: Int) -> FoldRange? { + guard let lineRange = controller?.textView.layoutManager.textLineForIndex(lineNumber)?.range else { return nil } + guard let deepestFold = foldCache.folds(in: lineRange.intRange).max(by: { + if $0.isCollapsed != $1.isCollapsed { + $1.isCollapsed // Collapsed folds take precedence. + } else if $0.isCollapsed { + $0.depth > $1.depth + } else { + $0.depth < $1.depth + } + }) else { + return nil + } + return deepestFold + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift new file mode 100644 index 000000000..40ecd262c --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -0,0 +1,33 @@ +// +// LineFoldPlaceholder.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/9/25. +// + +import AppKit +import CodeEditTextView + +class LineFoldPlaceholder: TextAttachment { + let fold: FoldRange + + init(fold: FoldRange) { + self.fold = fold + } + + var width: CGFloat { 17 } + + func draw(in context: CGContext, rect: NSRect) { + context.saveGState() + + let centerY = rect.midY - 1.5 + + context.setFillColor(NSColor.secondaryLabelColor.cgColor) + context.addEllipse(in: CGRect(x: rect.minX + 2, y: centerY, width: 3, height: 3)) + context.addEllipse(in: CGRect(x: rect.minX + 7, y: centerY, width: 3, height: 3)) + context.addEllipse(in: CGRect(x: rect.minX + 12, y: centerY, width: 3, height: 3)) + context.fillPath() + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift new file mode 100644 index 000000000..1a0650291 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -0,0 +1,298 @@ +// +// FoldingRibbonView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/8/25. +// + +import AppKit +import CodeEditTextView + +extension FoldingRibbonView { + struct DrawingFoldInfo { + let fold: FoldRange + let startLine: TextLineStorage.TextLinePosition + let endLine: TextLineStorage.TextLinePosition + } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext, + let layoutManager = model?.controller?.textView.layoutManager else { + return + } + + context.saveGState() + context.clip(to: dirtyRect) + + // Find the visible lines in the rect AppKit is asking us to draw. + guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), + let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { + return + } + let textRange = rangeStart.range.location.., + layoutManager: TextLayoutManager + ) -> [DrawingFoldInfo] { + var folds = model?.getFolds(in: textRange) ?? [] + + // Add in some fake depths, we can draw these underneath the rest of the folds to make it look like it's + // continuous + if let minimumDepth = folds.min(by: { $0.depth < $1.depth })?.depth { + for depth in (1.., + in context: CGContext + ) { + context.saveGState() + + let plainRect = foldCaps.adjustFoldRect( + using: foldInfo, + rect: NSRect( + x: -0.5, + y: yPosition.lowerBound, + width: frame.width + 1.0, + height: yPosition.upperBound - yPosition.lowerBound + ) + ) + let radius = plainRect.width / 2.0 + let roundedRect = NSBezierPath( + roundingRect: plainRect, + capTop: foldCaps.foldNeedsTopCap(foldInfo), + capBottom: foldCaps.foldNeedsBottomCap(foldInfo), + cornerRadius: radius + ) + roundedRect.transform(using: .init(translationByX: -0.5, byY: 0.0)) + + let combined = CGMutablePath() + combined.addPath(roundedRect.cgPathFallback) + combined.addPath(originalPath) + + context.clip( + to: CGRect( + x: 0, + y: yPosition.lowerBound, + width: 7, + height: yPosition.upperBound - yPosition.lowerBound + ) + ) + context.addPath(combined) + context.setFillColor(markerBorderColor) + context.drawPath(using: .eoFill) + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift new file mode 100644 index 000000000..1c766027a --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift @@ -0,0 +1,50 @@ +// +// FoldCapInfo.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/3/25. +// + +import AppKit + +extension FoldingRibbonView { + struct FoldCapInfo { + let startIndices: Set + let endIndices: Set + + init(_ folds: [DrawingFoldInfo]) { + self.startIndices = folds.reduce(into: Set(), { $0.insert($1.startLine.index) }) + self.endIndices = folds.reduce(into: Set(), { $0.insert($1.endLine.index) }) + } + + func foldNeedsTopCap(_ fold: DrawingFoldInfo) -> Bool { + endIndices.contains(fold.startLine.index) + } + + func foldNeedsBottomCap(_ fold: DrawingFoldInfo) -> Bool { + startIndices.contains(fold.endLine.index) + } + + func adjustFoldRect( + using fold: DrawingFoldInfo, + rect: NSRect + ) -> NSRect { + let capTop = foldNeedsTopCap(fold) + let capBottom = foldNeedsBottomCap(fold) + let yDelta = capTop ? fold.startLine.height / 2.0 : 0.0 + let heightDelta: CGFloat = if capTop && capBottom { + -fold.startLine.height + } else if capTop || capBottom { + -(fold.startLine.height / 2.0) + } else { + 0.0 + } + return NSRect( + x: rect.origin.x, + y: rect.origin.y + yDelta, + width: rect.size.width, + height: rect.size.height + heightDelta + ) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift similarity index 60% rename from Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift rename to Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index c06c9ac03..e83790d68 100644 --- a/Sources/CodeEditSourceEditor/Gutter/LineFolding/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -10,23 +10,21 @@ import AppKit import CodeEditTextView import Combine -#warning("Replace before release") -fileprivate let demoFoldProvider = IndentationLineFoldProvider() - /// Displays the code folding ribbon in the ``GutterView``. +/// +/// This view draws its contents class FoldingRibbonView: NSView { - struct HoveringFold: Equatable { - let range: ClosedRange - let depth: Int - } + +#warning("Replace before release") + private static let demoFoldProvider = IndentationLineFoldProvider() static let width: CGFloat = 7.0 - var model: LineFoldingModel + var model: LineFoldingModel? // Disabling this lint rule because this initial value is required for @Invalidating @Invalidating(.display) - var hoveringFold: HoveringFold? = nil // swiftlint:disable:this redundant_optional_initialization + var hoveringFold: FoldRange? = nil // swiftlint:disable:this redundant_optional_initialization var hoverAnimationTimer: Timer? @Invalidating(.display) var hoverAnimationProgress: CGFloat = 0.0 @@ -82,33 +80,30 @@ class FoldingRibbonView: NSView { } }.cgColor - private var foldUpdateCancellable: AnyCancellable? - override public var isFlipped: Bool { true } - init(textView: TextView, foldProvider: LineFoldProvider?) { - #warning("Replace before release") - self.model = LineFoldingModel( - textView: textView, - foldProvider: foldProvider ?? demoFoldProvider - ) + init(controller: TextViewController, foldProvider: LineFoldProvider?) { super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay clipsToBounds = false - foldUpdateCancellable = model.foldsUpdatedPublisher.sink { - self.needsDisplay = true - } + #warning("Replace before release") + self.model = LineFoldingModel( + controller: controller, + foldView: self, + foldProvider: foldProvider ?? Self.demoFoldProvider + ) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - deinit { - foldUpdateCancellable?.cancel() + override public func resetCursorRects() { + // Don't use an iBeam in this view + addCursorRect(bounds, cursor: .arrow) } // MARK: - Hover @@ -124,17 +119,59 @@ class FoldingRibbonView: NSView { addTrackingArea(area) } + var attachments: [LineFoldPlaceholder] = [] + + override func scrollWheel(with event: NSEvent) { + super.scrollWheel(with: event) + self.mouseMoved(with: event) + } + + override func mouseDown(with event: NSEvent) { + let clickPoint = convert(event.locationInWindow, from: nil) + guard let layoutManager = model?.controller?.textView.layoutManager, + event.type == .leftMouseDown, + let lineNumber = layoutManager.textLineForPosition(clickPoint.y)?.index, + let fold = model?.getCachedFoldAt(lineNumber: lineNumber), + let firstLineInFold = layoutManager.textLineForOffset(fold.range.lowerBound) else { + super.mouseDown(with: event) + return + } + + if let attachment = findAttachmentFor(fold: fold, firstLineRange: firstLineInFold.range) { + layoutManager.attachments.remove(atOffset: attachment.range.location) + attachments.removeAll(where: { $0 === attachment.attachment }) + } else { + let placeholder = LineFoldPlaceholder(fold: fold) + layoutManager.attachments.add(placeholder, for: NSRange(fold.range)) + attachments.append(placeholder) + } + + model?.foldCache.toggleCollapse(forFold: fold) + model?.controller?.textView.needsLayout = true + } + + private func findAttachmentFor(fold: FoldRange, firstLineRange: NSRange) -> AnyTextAttachment? { + model?.controller?.textView?.layoutManager.attachments + .getAttachmentsStartingIn(NSRange(fold.range)) + .filter({ + $0.attachment is LineFoldPlaceholder && firstLineRange.contains($0.range.location) + }).first + } + override func mouseMoved(with event: NSEvent) { + defer { + super.mouseMoved(with: event) + } + let pointInView = convert(event.locationInWindow, from: nil) - guard let lineNumber = model.textView?.layoutManager.textLineForPosition(pointInView.y)?.index, - let fold = model.getCachedFoldAt(lineNumber: lineNumber) else { + guard let lineNumber = model?.controller?.textView.layoutManager.textLineForPosition(pointInView.y)?.index, + let fold = model?.getCachedFoldAt(lineNumber: lineNumber) else { hoverAnimationProgress = 0.0 hoveringFold = nil return } - let newHoverRange = HoveringFold(range: fold.range.lineRange, depth: fold.depth) - guard newHoverRange != hoveringFold else { + guard fold.range != hoveringFold?.range else { return } hoverAnimationTimer?.invalidate() @@ -142,7 +179,7 @@ class FoldingRibbonView: NSView { // show it immediately. if hoveringFold == nil { hoverAnimationProgress = 0.0 - hoveringFold = newHoverRange + hoveringFold = fold let duration: TimeInterval = 0.2 let startTime = CACurrentMediaTime() @@ -160,10 +197,11 @@ class FoldingRibbonView: NSView { // Don't animate these hoverAnimationProgress = 1.0 - hoveringFold = newHoverRange + hoveringFold = fold } override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) hoverAnimationProgress = 0.0 hoveringFold = nil } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift index 363768d2e..0c3438e09 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore+FindIndex.swift @@ -12,4 +12,11 @@ extension RangeStore { func findIndex(at offset: Int) -> (index: Index, remaining: Int) { _guts.find(at: offset, in: OffsetMetric(), preferEnd: false) } + + /// Finds the value stored at a given string offset. + /// - Parameter offset: The offset to query for. + /// - Returns: The element stored, if any. + func findValue(at offset: Int) -> Element? { + _guts[findIndex(at: offset).index].value + } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 5e22fc5f8..164cf1ad2 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift @@ -6,6 +6,7 @@ // import _RopeModule +import Foundation /// RangeStore is a container type that allows for setting and querying values for relative ranges in text. The /// container reflects a text document in that its length needs to be kept up-to-date. It can efficiently remove and @@ -49,7 +50,7 @@ struct RangeStore: Sendable { var index = findIndex(at: range.lowerBound).index var offset: Int? = range.lowerBound - _guts.offset(of: index, in: OffsetMetric()) - while index < _guts.endIndex { + while index < _guts.endIndex, _guts.offset(of: index, in: OffsetMetric()) < range.upperBound { let run = _guts[index] runs.append(Run(length: run.length - (offset ?? 0), value: run.value)) @@ -96,6 +97,22 @@ struct RangeStore: Sendable { // MARK: - Storage Sync extension RangeStore { + /// Handles keeping the internal storage in sync with the document. + mutating func storageUpdated(editedRange: NSRange, changeInLength delta: Int) { + let storageRange: Range + let newLength: Int + + if editedRange.length == 0 { // Deleting, editedRange is at beginning of the range that was deleted + storageRange = editedRange.location..<(editedRange.location - delta) + newLength = 0 + } else { // Replacing or inserting + storageRange = editedRange.location..<(editedRange.location + editedRange.length - delta) + newLength = editedRange.length + } + + storageUpdated(replacedCharactersIn: storageRange, withCount: newLength) + } + /// Handles keeping the internal storage in sync with the document. mutating func storageUpdated(replacedCharactersIn range: Range, withCount newLength: Int) { assert(range.lowerBound >= 0, "Negative lowerBound") diff --git a/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift b/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift index 4de83868e..b2815fbf6 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/Atomic.swift @@ -22,6 +22,10 @@ final package class Atomic { } } + func withValue(_ handler: (T) -> F) -> F { + lock.withLock { handler(wrappedValue) } + } + func value() -> T { lock.withLock { wrappedValue } } diff --git a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift index ce67a0c93..89d934914 100644 --- a/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift +++ b/Sources/CodeEditSourceEditor/TreeSitter/LanguageLayer.swift @@ -158,7 +158,7 @@ public class LanguageLayer: Hashable { } wasLongParse = true } - newTree = DispatchQueue.syncMainIfNot { parser.parse(tree: tree, readBlock: readBlock) } + newTree = DispatchQueue.waitMainIfNot { parser.parse(tree: tree, readBlock: readBlock) } } if wasLongParse { diff --git a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift deleted file mode 100644 index ba2eb1530..000000000 --- a/Tests/CodeEditSourceEditorTests/FindPanelViewModelTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// FindPanelViewModelTests.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/25/25. -// - -import Testing -import AppKit -import CodeEditTextView -@testable import CodeEditSourceEditor - -@MainActor -struct FindPanelViewModelTests { - class MockPanelTarget: FindPanelTarget { - var emphasisManager: EmphasisManager? - var text: String = "" - var findPanelTargetView: NSView - var cursorPositions: [CursorPosition] = [] - var textView: TextView! - - @MainActor init() { - findPanelTargetView = NSView() - textView = TextView(string: text) - } - - func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) { } - func updateCursorPosition() { } - func findPanelWillShow(panelHeight: CGFloat) { } - func findPanelWillHide(panelHeight: CGFloat) { } - func findPanelModeDidChange(to mode: FindPanelMode) { } - } - - @Test func viewModelHeightUpdates() async throws { - let model = FindPanelViewModel(target: MockPanelTarget()) - model.mode = .find - #expect(model.panelHeight == 28) - - model.mode = .replace - #expect(model.panelHeight == 54) - } -} diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift new file mode 100644 index 000000000..44467bde2 --- /dev/null +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldStorageTests.swift @@ -0,0 +1,53 @@ +// +// LineFoldStorageTests.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 5/29/25. +// + +import Testing +@testable import CodeEditSourceEditor + +struct LineFoldStorageTests { + // Helper to create a collapsed provider set + private func collapsedSet(_ items: (Int, Int)...) -> Set { + Set(items.map { (depth, start) in + LineFoldStorage.DepthStartPair(depth: depth, start: start) + }) + } + + @Test + func emptyStorage() { + let storage = LineFoldStorage(documentLength: 50) + let folds = storage.folds(in: 0..<50) + #expect(folds.isEmpty) + } + + @Test + func updateFoldsBasic() { + var storage = LineFoldStorage(documentLength: 20) + let raw: [LineFoldStorage.RawFold] = [ + LineFoldStorage.RawFold(depth: 1, range: 0..<5), + LineFoldStorage.RawFold(depth: 2, range: 5..<10) + ] + storage.updateFolds(from: raw, collapsedRanges: []) + + let folds = storage.folds(in: 0..<20) + #expect(folds.count == 2) + #expect(folds[0].depth == 1 && folds[0].range == 0..<5 && folds[0].isCollapsed == false) + #expect(folds[1].depth == 2 && folds[1].range == 5..<10 && folds[1].isCollapsed == false) + } + + @Test + func preserveCollapseState() { + var storage = LineFoldStorage(documentLength: 15) + let raw = [LineFoldStorage.RawFold(depth: 1, range: 0..<5)] + // First pass: no collapsed + storage.updateFolds(from: raw, collapsedRanges: []) + #expect(storage.folds(in: 0..<15).first?.isCollapsed == false) + + // Second pass: provider marks depth=1, start=0 as collapsed + storage.updateFolds(from: raw, collapsedRanges: collapsedSet((1, 0))) + #expect(storage.folds(in: 0..<15).first?.isCollapsed == true) + } +} diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift index 4ba76f767..168feab50 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -10,46 +10,60 @@ import AppKit import CodeEditTextView @testable import CodeEditSourceEditor -@Suite @MainActor struct LineFoldingModelTests { /// Makes a fold pattern that increases until halfway through the document then goes back to zero. + @MainActor class HillPatternFoldProvider: LineFoldProvider { - func foldLevelAtLine(_ lineNumber: Int, layoutManager: TextLayoutManager, textStorage: NSTextStorage) -> Int? { - let halfLineCount = (layoutManager.lineCount / 2) - 1 + func foldLevelAtLine( + lineNumber: Int, + lineRange: NSRange, + previousDepth: Int, + controller: TextViewController + ) -> [LineFoldProviderLineInfo] { + let halfLineCount = (controller.textView.layoutManager.lineCount / 2) - 1 return if lineNumber > halfLineCount { - layoutManager.lineCount - 2 - lineNumber + [ + .startFold( + rangeStart: lineRange.location, + newDepth: controller.textView.layoutManager.lineCount - 2 - lineNumber + ) + ] } else { - lineNumber + [ + .endFold(rangeEnd: lineRange.location, newDepth: lineNumber) + ] } } } + let controller: TextViewController let textView: TextView - let model: LineFoldingModel init() { - textView = TextView(string: "A\nB\nC\nD\nE\nF\n") + controller = Mock.textViewController(theme: Mock.theme()) + textView = controller.textView + textView.string = "A\nB\nC\nD\nE\nF\n" textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) textView.updatedViewport(NSRect(x: 0, y: 0, width: 1000, height: 1000)) - model = LineFoldingModel(textView: textView, foldProvider: nil) } /// A little unintuitive but we only expect two folds with this. Our provider goes 0-1-2-2-1-0, but we don't /// make folds for indent level 0. We also expect folds to start on the lines *before* the indent increases and /// after it decreases, so the fold covers the start/end of the region being folded. @Test - func buildFoldsForDocument() throws { + func buildFoldsForDocument() async throws { let provider = HillPatternFoldProvider() - model.foldProvider = provider + let model = LineFoldingModel(controller: controller, foldView: NSView(), foldProvider: provider) - model.buildFoldsForDocument() + var cacheUpdated = model.$foldCache.values.makeAsyncIterator() + _ = await cacheUpdated.next() + _ = await cacheUpdated.next() - let fold = try #require(model.getFolds(in: 0...5).first) - #expect(fold.lineRange == 0...5) - - let innerFold = try #require(fold.subFolds.first) - #expect(innerFold.lineRange == 1...4) + let fold = try #require(model.getFolds(in: 0..<6).first) + #expect(fold.range == 2..<10) + #expect(fold.depth == 1) + #expect(fold.isCollapsed == false) } } From 9a134d1cbcdc0efe78db4190c7e3c7ecb5d52be2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Jun 2025 19:05:05 -0500 Subject: [PATCH 04/14] Folding Ribbon: Collapsed Indicators, Masking, Bracket Emphasis (#332) --- .../TextViewController+TextFormation.swift | 17 -- .../Enums/BracketPairs.swift | 29 ++++ .../NSBezierPath+RoundedCorners.swift | 1 + .../Extensions/NSColor+LightDark.swift | 23 +++ .../Extensions/NSRect+Transform.swift | 19 +++ .../Model/LineFoldCalculator.swift | 6 +- .../LineFolding/Model/LineFoldingModel.swift | 34 ++++ .../Placeholder/LineFoldPlaceholder.swift | 28 +++- .../View/FoldingRibbonView+Draw.swift | 154 ++++++++++------- .../View/FoldingRibbonView+FoldCapInfo.swift | 58 +++++-- .../LineFolding/View/FoldingRibbonView.swift | 156 ++++++++++-------- .../RangeStore/RangeStore.swift | 5 +- 12 files changed, 361 insertions(+), 169 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Enums/BracketPairs.swift create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 002e3807b..896c34777 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -11,23 +11,6 @@ import TextFormation import TextStory extension TextViewController { - - internal enum BracketPairs { - static let allValues: [(String, String)] = [ - ("{", "}"), - ("[", "]"), - ("(", ")"), - ("\"", "\""), - ("'", "'") - ] - - static let emphasisValues: [(String, String)] = [ - ("{", "}"), - ("[", "]"), - ("(", ")") - ] - } - // MARK: - Filter Configuration /// Initializes any filters for text editing. diff --git a/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift new file mode 100644 index 000000000..bce82896c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Enums/BracketPairs.swift @@ -0,0 +1,29 @@ +// +// BracketPairs.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/5/25. +// + +enum BracketPairs { + static let allValues: [(String, String)] = [ + ("{", "}"), + ("[", "]"), + ("(", ")"), + ("\"", "\""), + ("'", "'") + ] + + static let emphasisValues: [(String, String)] = [ + ("{", "}"), + ("[", "]"), + ("(", ")") + ] + + /// Checks if the given string is a matchable emphasis string. + /// - Parameter potentialMatch: The string to check for matches. + /// - Returns: True if a match was found with either start or end bracket pairs. + static func matches(_ potentialMatch: String) -> Bool { + allValues.contains(where: { $0.0 == potentialMatch || $0.1 == potentialMatch }) + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift index 09156caea..25208fdcd 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSBezierPath+RoundedCorners.swift @@ -22,6 +22,7 @@ extension NSBezierPath { public static let bottomLeft = Corners(rawValue: 1 << 1) public static let topRight = Corners(rawValue: 1 << 2) public static let bottomRight = Corners(rawValue: 1 << 3) + public static let all: Corners = Corners(rawValue: 0b1111) } // swiftlint:disable:next function_body_length diff --git a/Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift b/Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift new file mode 100644 index 000000000..639c9f3e2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSColor+LightDark.swift @@ -0,0 +1,23 @@ +// +// NSColor+LightDark.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/4/25. +// + +import AppKit + +extension NSColor { + convenience init(light: NSColor, dark: NSColor) { + self.init(name: nil) { appearance in + return switch appearance.name { + case .aqua: + light + case .darkAqua: + dark + default: + NSColor() + } + } + } +} diff --git a/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift new file mode 100644 index 000000000..34be2a8e7 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSRect+Transform.swift @@ -0,0 +1,19 @@ +// +// NSRect+Transform.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/4/25. +// + +import AppKit + +extension NSRect { + func transform(x xVal: CGFloat = 0, y yVal: CGFloat = 0, width: CGFloat = 0, height: CGFloat = 0) -> NSRect { + NSRect( + x: self.origin.x + xVal, + y: self.origin.y + yVal, + width: self.width + width, + height: self.height + height + ) + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 3a12022d0..92cabb8c9 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -19,7 +19,7 @@ actor LineFoldCalculator { private var valueStreamContinuation: AsyncStream.Continuation private var textChangedTask: Task? - + /// Create a new calculator object that listens to a given stream for text changes. /// - Parameters: /// - foldProvider: The object to use to calculate fold regions. @@ -39,7 +39,7 @@ actor LineFoldCalculator { deinit { textChangedTask?.cancel() } - + /// Sets up an attached task to listen to values on a stream of text changes. /// - Parameter textChangedStream: A stream of text changes. private func listenToTextChanges(textChangedStream: AsyncStream<(NSRange, Int)>) { @@ -105,7 +105,7 @@ actor LineFoldCalculator { await yieldNewStorage(newFolds: foldCache, controller: controller, documentRange: documentRange) } - + /// Yield a new storage value on the value stream using a new set of folds. /// - Parameters: /// - newFolds: The new folds to yield with the storage value. diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index 08137c418..53b00cc9a 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -19,6 +19,9 @@ import Combine /// - Loop through the list, creating nested folds as indents go up and down. /// class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { + static let emphasisId = "lineFolding" + + /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` /// and ``FoldRange/subFolds``. @Published var foldCache: LineFoldStorage = LineFoldStorage(documentLength: 0) @@ -92,4 +95,35 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { } return deepestFold } + + func emphasizeBracketsForFold(_ fold: FoldRange) { + clearEmphasis() + + // Find the text object, make sure there's available characters around the fold. + guard let text = controller?.textView.textStorage.string as? NSString, + fold.range.lowerBound > 0 && fold.range.upperBound < text.length - 1 else { + return + } + + let firstRange = NSRange(location: fold.range.lowerBound - 1, length: 1) + let secondRange = NSRange(location: fold.range.upperBound, length: 1) + + // Check if these are emphasizable bracket pairs. + guard BracketPairs.matches(text.substring(from: firstRange) ?? "") + && BracketPairs.matches(text.substring(from: secondRange) ?? "") else { + return + } + + controller?.textView.emphasisManager?.addEmphases( + [ + Emphasis(range: firstRange, style: .standard, flash: false, inactive: false, selectInDocument: false), + Emphasis(range: secondRange, style: .standard, flash: false, inactive: false, selectInDocument: false), + ], + for: Self.emphasisId + ) + } + + func clearEmphasis() { + controller?.textView.emphasisManager?.removeEmphases(for: Self.emphasisId) + } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift index 40ecd262c..a796376aa 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -10,22 +10,40 @@ import CodeEditTextView class LineFoldPlaceholder: TextAttachment { let fold: FoldRange + let charWidth: CGFloat + var isSelected: Bool = false - init(fold: FoldRange) { + init(fold: FoldRange, charWidth: CGFloat) { self.fold = fold + self.charWidth = charWidth } - var width: CGFloat { 17 } + var width: CGFloat { + charWidth * 5 + } func draw(in context: CGContext, rect: NSRect) { context.saveGState() let centerY = rect.midY - 1.5 + if isSelected { + context.setFillColor(NSColor.controlAccentColor.cgColor) + context.addPath( + NSBezierPath( + rect: rect.transform(x: 2.0, y: 3.0, width: -4.0, height: -6.0 ), + roundedCorners: .all, + cornerRadius: 2 + ).cgPathFallback + ) + context.fillPath() + } + context.setFillColor(NSColor.secondaryLabelColor.cgColor) - context.addEllipse(in: CGRect(x: rect.minX + 2, y: centerY, width: 3, height: 3)) - context.addEllipse(in: CGRect(x: rect.minX + 7, y: centerY, width: 3, height: 3)) - context.addEllipse(in: CGRect(x: rect.minX + 12, y: centerY, width: 3, height: 3)) + let size = charWidth / 2 + context.addEllipse(in: CGRect(x: rect.minX + charWidth * 1.25, y: centerY, width: size, height: size)) + context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 2.25), y: centerY, width: size, height: size)) + context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 3.25), y: centerY, width: size, height: size)) context.fillPath() context.restoreGState() diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift index 1a0650291..3680d832f 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift @@ -15,23 +15,26 @@ extension FoldingRibbonView { let endLine: TextLineStorage.TextLinePosition } + // MARK: - Draw + override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext, - let layoutManager = model?.controller?.textView.layoutManager else { + let layoutManager = model?.controller?.textView.layoutManager, + // Find the visible lines in the rect AppKit is asking us to draw. + let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), + let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { return } context.saveGState() context.clip(to: dirtyRect) - // Find the visible lines in the rect AppKit is asking us to draw. - guard let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), - let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { - return - } - let textRange = rangeStart.range.location.. 1. In this case, we still need to draw those + /// layers of color to create the illusion that those folds are continuous under the nested folds. To achieve this, + /// we create 'fake' folds that span more than the queried text range. When returned for drawing, the drawing + /// methods will draw those extra folds normally. + /// + /// - Parameters: + /// - textRange: The range of characters in text to create drawing fold info for. + /// - layoutManager: A layout manager to query for line layout information. + /// - Returns: A list of folds to draw for the given text range. private func getDrawingFolds( forTextRange textRange: Range, layoutManager: TextLayoutManager @@ -85,14 +102,15 @@ extension FoldingRibbonView { } } + // MARK: - Draw Fold Marker + /// Draw a single fold marker for a fold. /// /// Ensure the correct fill color is set on the drawing context before calling. /// /// - Parameters: /// - foldInfo: The fold to draw. - /// - markerContext: The context in which the fold is being drawn, including the depth and if a line is - /// being hovered. + /// - foldCaps: /// - context: The drawing context to use. /// - layoutManager: A layout manager used to retrieve position information for lines. private func drawFoldMarker( @@ -103,34 +121,43 @@ extension FoldingRibbonView { ) { let minYPosition = foldInfo.startLine.yPos let maxYPosition = foldInfo.endLine.yPos + foldInfo.endLine.height + let foldRect = NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) if foldInfo.fold.isCollapsed { - drawCollapsedFold(minYPosition: minYPosition, maxYPosition: maxYPosition, in: context) - } else if let hoveringFold, hoveringFold.isHoveringEqual(foldInfo.fold) { - drawHoveredFold( + drawCollapsedFold( + foldInfo: foldInfo, minYPosition: minYPosition, maxYPosition: maxYPosition, in: context ) + } else if hoveringFold.fold?.isHoveringEqual(foldInfo.fold) == true { + drawHoveredFold( + foldInfo: foldInfo, + foldCaps: foldCaps, + foldRect: foldRect, + in: context + ) } else { drawNestedFold( foldInfo: foldInfo, foldCaps: foldCaps, - minYPosition: minYPosition, - maxYPosition: maxYPosition, + foldRect: foldCaps.adjustFoldRect(using: foldInfo, rect: foldRect), in: context ) } } + // MARK: - Collapsed Fold + private func drawCollapsedFold( + foldInfo: DrawingFoldInfo, minYPosition: CGFloat, maxYPosition: CGFloat, in context: CGContext ) { context.saveGState() - let fillRect = CGRect(x: 0, y: minYPosition, width: Self.width, height: maxYPosition - minYPosition) + let fillRect = CGRect(x: 0, y: minYPosition + 1.0, width: Self.width, height: maxYPosition - minYPosition - 2.0) let height = 5.0 let minX = 2.0 @@ -144,12 +171,18 @@ extension FoldingRibbonView { chevron.addLine(to: CGPoint(x: maxX, y: centerY)) chevron.addLine(to: CGPoint(x: minX, y: maxY)) - context.setStrokeColor(NSColor.secondaryLabelColor.cgColor) + if let hoveringFoldMask = hoveringFold.foldMask, + hoveringFoldMask.intersects(CGPath(rect: fillRect, transform: .none)) { + context.addPath(hoveringFoldMask) + context.clip() + } + + context.setStrokeColor(foldedIndicatorChevronColor) context.setLineCap(.round) context.setLineJoin(.round) context.setLineWidth(1.3) - context.setFillColor(NSColor.tertiaryLabelColor.cgColor) + context.setFillColor(foldedIndicatorColor) context.fill(fillRect) context.addPath(chevron) context.strokePath() @@ -157,23 +190,38 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Hovered Fold + private func drawHoveredFold( - minYPosition: CGFloat, - maxYPosition: CGFloat, + foldInfo: DrawingFoldInfo, + foldCaps: FoldCapInfo, + foldRect: NSRect, in context: CGContext ) { context.saveGState() - let plainRect = NSRect(x: -2, y: minYPosition, width: 11.0, height: maxYPosition - minYPosition) - let roundedRect = NSBezierPath(roundedRect: plainRect, xRadius: 11.0 / 2, yRadius: 11.0 / 2) + let plainRect = foldRect.transform(x: -2.0, y: -1.0, width: 4.0, height: 2.0) + let roundedRect = NSBezierPath( + roundedRect: plainRect, + xRadius: plainRect.width / 2, + yRadius: plainRect.width / 2 + ) - context.setFillColor(hoverFillColor.copy(alpha: hoverAnimationProgress) ?? hoverFillColor) - context.setStrokeColor(hoverBorderColor.copy(alpha: hoverAnimationProgress) ?? hoverBorderColor) + context.setFillColor(hoverFillColor.copy(alpha: hoveringFold.progress) ?? hoverFillColor) + context.setStrokeColor(hoverBorderColor.copy(alpha: hoveringFold.progress) ?? hoverBorderColor) context.addPath(roundedRect.cgPathFallback) context.drawPath(using: .fillStroke) - // Add the little arrows - drawChevron(in: context, yPosition: minYPosition + 8, pointingUp: false) - drawChevron(in: context, yPosition: maxYPosition - 8, pointingUp: true) + // Add the little arrows if we're not hovering right on a collapsed guy + if foldCaps.hoveredFoldShouldDrawTopChevron(foldInfo) { + drawChevron(in: context, yPosition: plainRect.minY + 8, pointingUp: false) + } + if foldCaps.hoveredFoldShouldDrawBottomChevron(foldInfo) { + drawChevron(in: context, yPosition: plainRect.maxY - 8, pointingUp: true) + } + + let plainMaskRect = foldRect.transform(y: 1.0, height: -2.0) + let roundedMaskRect = NSBezierPath(roundedRect: plainMaskRect, xRadius: Self.width / 2, yRadius: Self.width / 2) + hoveringFold.foldMask = roundedMaskRect.cgPathFallback context.restoreGState() } @@ -187,9 +235,13 @@ extension FoldingRibbonView { let minX = center - (chevronSize.width / 2) let maxX = center + (chevronSize.width / 2) - let startY = pointingUp ? yPosition + chevronSize.height : yPosition - chevronSize.height + let startY = if pointingUp { + yPosition + chevronSize.height + } else { + yPosition - chevronSize.height + } - context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoverAnimationProgress).cgColor) + context.setStrokeColor(NSColor.secondaryLabelColor.withAlphaComponent(hoveringFold.progress).cgColor) context.setLineCap(.round) context.setLineJoin(.round) context.setLineWidth(1.3) @@ -203,24 +255,20 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Nested Fold + private func drawNestedFold( foldInfo: DrawingFoldInfo, foldCaps: FoldCapInfo, - minYPosition: CGFloat, - maxYPosition: CGFloat, + foldRect: NSRect, in context: CGContext ) { context.saveGState() - let plainRect = foldCaps.adjustFoldRect( - using: foldInfo, - rect: NSRect(x: 0, y: minYPosition + 1, width: 7, height: maxYPosition - minYPosition - 2) - ) - let radius = plainRect.width / 2.0 let roundedRect = NSBezierPath( - roundingRect: plainRect, + roundingRect: foldRect, capTop: foldCaps.foldNeedsTopCap(foldInfo), capBottom: foldCaps.foldNeedsBottomCap(foldInfo), - cornerRadius: radius + cornerRadius: foldRect.width / 2.0 ) context.setFillColor(markerColor) @@ -232,8 +280,8 @@ extension FoldingRibbonView { drawOutline( foldInfo: foldInfo, foldCaps: foldCaps, + foldRect: foldRect, originalPath: roundedRect.cgPathFallback, - yPosition: minYPosition...maxYPosition, in: context ) } @@ -241,6 +289,8 @@ extension FoldingRibbonView { context.restoreGState() } + // MARK: - Nested Outline + /// Draws a rounded outline for a rectangle, creating the small, light, outline around each fold indicator. /// /// This function does not change fill colors for the given context. @@ -253,42 +303,26 @@ extension FoldingRibbonView { private func drawOutline( foldInfo: DrawingFoldInfo, foldCaps: FoldCapInfo, + foldRect: NSRect, originalPath: CGPath, - yPosition: ClosedRange, in context: CGContext ) { context.saveGState() - let plainRect = foldCaps.adjustFoldRect( - using: foldInfo, - rect: NSRect( - x: -0.5, - y: yPosition.lowerBound, - width: frame.width + 1.0, - height: yPosition.upperBound - yPosition.lowerBound - ) - ) - let radius = plainRect.width / 2.0 + let plainRect = foldRect.transform(x: -1.0, y: -1.0, width: 2.0, height: 2.0) let roundedRect = NSBezierPath( roundingRect: plainRect, capTop: foldCaps.foldNeedsTopCap(foldInfo), capBottom: foldCaps.foldNeedsBottomCap(foldInfo), - cornerRadius: radius + cornerRadius: plainRect.width / 2.0 ) - roundedRect.transform(using: .init(translationByX: -0.5, byY: 0.0)) + roundedRect.transform(using: .init(translationByX: -1.0, byY: 0.0)) let combined = CGMutablePath() combined.addPath(roundedRect.cgPathFallback) combined.addPath(originalPath) - context.clip( - to: CGRect( - x: 0, - y: yPosition.lowerBound, - width: 7, - height: yPosition.upperBound - yPosition.lowerBound - ) - ) + context.clip(to: foldRect.transform(y: -1.0, height: 2.0)) context.addPath(combined) context.setFillColor(markerBorderColor) context.drawPath(using: .eoFill) diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift index 1c766027a..3a77e86c5 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+FoldCapInfo.swift @@ -8,21 +8,51 @@ import AppKit extension FoldingRibbonView { + /// A helper type that determines if a fold should be drawn with a cap on the top or bottom if + /// there's an adjacent fold on the same text line. It also provides a helper method to adjust fold rects using + /// the cap information. struct FoldCapInfo { - let startIndices: Set - let endIndices: Set + private let startIndices: Set + private let endIndices: Set + private let collapsedStartIndices: Set + private let collapsedEndIndices: Set init(_ folds: [DrawingFoldInfo]) { - self.startIndices = folds.reduce(into: Set(), { $0.insert($1.startLine.index) }) - self.endIndices = folds.reduce(into: Set(), { $0.insert($1.endLine.index) }) + var startIndices = Set() + var endIndices = Set() + var collapsedStartIndices = Set() + var collapsedEndIndices = Set() + + for fold in folds { + if fold.fold.isCollapsed { + collapsedStartIndices.insert(fold.startLine.index) + collapsedEndIndices.insert(fold.endLine.index) + } else { + startIndices.insert(fold.startLine.index) + endIndices.insert(fold.endLine.index) + } + } + + self.startIndices = startIndices + self.endIndices = endIndices + self.collapsedStartIndices = collapsedStartIndices + self.collapsedEndIndices = collapsedEndIndices } func foldNeedsTopCap(_ fold: DrawingFoldInfo) -> Bool { - endIndices.contains(fold.startLine.index) + endIndices.contains(fold.startLine.index) || collapsedEndIndices.contains(fold.startLine.index) } func foldNeedsBottomCap(_ fold: DrawingFoldInfo) -> Bool { - startIndices.contains(fold.endLine.index) + startIndices.contains(fold.endLine.index) || collapsedStartIndices.contains(fold.endLine.index) + } + + func hoveredFoldShouldDrawTopChevron(_ fold: DrawingFoldInfo) -> Bool { + !collapsedEndIndices.contains(fold.startLine.index) + } + + func hoveredFoldShouldDrawBottomChevron(_ fold: DrawingFoldInfo) -> Bool { + !collapsedStartIndices.contains(fold.endLine.index) } func adjustFoldRect( @@ -31,14 +61,20 @@ extension FoldingRibbonView { ) -> NSRect { let capTop = foldNeedsTopCap(fold) let capBottom = foldNeedsBottomCap(fold) - let yDelta = capTop ? fold.startLine.height / 2.0 : 0.0 - let heightDelta: CGFloat = if capTop && capBottom { - -fold.startLine.height - } else if capTop || capBottom { - -(fold.startLine.height / 2.0) + let yDelta: CGFloat = if capTop && !collapsedEndIndices.contains(fold.startLine.index) { + fold.startLine.height / 2.0 } else { 0.0 } + + var heightDelta: CGFloat = 0.0 + if capTop && !collapsedEndIndices.contains(fold.startLine.index) { + heightDelta -= fold.startLine.height / 2.0 + } + if capBottom && !collapsedStartIndices.contains(fold.endLine.index) { + heightDelta -= fold.endLine.height / 2.0 + } + return NSRect( x: rect.origin.x, y: rect.origin.y + yDelta, diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index e83790d68..54e86f4c9 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -8,12 +8,23 @@ import Foundation import AppKit import CodeEditTextView -import Combine /// Displays the code folding ribbon in the ``GutterView``. /// /// This view draws its contents class FoldingRibbonView: NSView { + struct HoverAnimationDetails: Equatable { + var fold: FoldRange? + var foldMask: CGPath? + var timer: Timer? + var progress: CGFloat = 0.0 + + static let empty = HoverAnimationDetails() + + public static func == (_ lhs: HoverAnimationDetails, _ rhs: HoverAnimationDetails) -> Bool { + lhs.fold == rhs.fold && lhs.foldMask == rhs.foldMask && lhs.progress == rhs.progress + } + } #warning("Replace before release") private static let demoFoldProvider = IndentationLineFoldProvider() @@ -22,63 +33,47 @@ class FoldingRibbonView: NSView { var model: LineFoldingModel? - // Disabling this lint rule because this initial value is required for @Invalidating @Invalidating(.display) - var hoveringFold: FoldRange? = nil // swiftlint:disable:this redundant_optional_initialization - var hoverAnimationTimer: Timer? - @Invalidating(.display) - var hoverAnimationProgress: CGFloat = 0.0 + var hoveringFold: HoverAnimationDetails = .empty @Invalidating(.display) var backgroundColor: NSColor = NSColor.controlBackgroundColor @Invalidating(.display) - var markerColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 0.0, alpha: 0.1) - case .darkAqua: - NSColor(deviceWhite: 1.0, alpha: 0.2) - default: - NSColor() - } - }.cgColor + var markerColor = NSColor( + light: NSColor(deviceWhite: 0.0, alpha: 0.1), + dark: NSColor(deviceWhite: 1.0, alpha: 0.2) + ).cgColor @Invalidating(.display) - var markerBorderColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 1.0, alpha: 0.4) - case .darkAqua: - NSColor(deviceWhite: 0.0, alpha: 0.4) - default: - NSColor() - } - }.cgColor + var markerBorderColor = NSColor( + light: NSColor(deviceWhite: 1.0, alpha: 0.4), + dark: NSColor(deviceWhite: 0.0, alpha: 0.4) + ).cgColor @Invalidating(.display) - var hoverFillColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 1.0, alpha: 1.0) - case .darkAqua: - NSColor(deviceWhite: 0.17, alpha: 1.0) - default: - NSColor() - } - }.cgColor + var hoverFillColor = NSColor( + light: NSColor(deviceWhite: 1.0, alpha: 1.0), + dark: NSColor(deviceWhite: 0.17, alpha: 1.0) + ).cgColor @Invalidating(.display) - var hoverBorderColor = NSColor(name: nil) { appearance in - return switch appearance.name { - case .aqua: - NSColor(deviceWhite: 0.8, alpha: 1.0) - case .darkAqua: - NSColor(deviceWhite: 0.4, alpha: 1.0) - default: - NSColor() - } - }.cgColor + var hoverBorderColor = NSColor( + light: NSColor(deviceWhite: 0.8, alpha: 1.0), + dark: NSColor(deviceWhite: 0.4, alpha: 1.0) + ).cgColor + + @Invalidating(.display) + var foldedIndicatorColor = NSColor( + light: NSColor(deviceWhite: 0.0, alpha: 0.3), + dark: NSColor(deviceWhite: 1.0, alpha: 0.6) + ).cgColor + + @Invalidating(.display) + var foldedIndicatorChevronColor = NSColor( + light: NSColor(deviceWhite: 1.0, alpha: 1.0), + dark: NSColor(deviceWhite: 0.0, alpha: 1.0) + ).cgColor override public var isFlipped: Bool { true @@ -141,13 +136,14 @@ class FoldingRibbonView: NSView { layoutManager.attachments.remove(atOffset: attachment.range.location) attachments.removeAll(where: { $0 === attachment.attachment }) } else { - let placeholder = LineFoldPlaceholder(fold: fold) + let placeholder = LineFoldPlaceholder(fold: fold, charWidth: model?.controller?.fontCharWidth ?? 1.0) layoutManager.attachments.add(placeholder, for: NSRange(fold.range)) attachments.append(placeholder) } model?.foldCache.toggleCollapse(forFold: fold) model?.controller?.textView.needsLayout = true + mouseMoved(with: event) } private func findAttachmentFor(fold: FoldRange, firstLineRange: NSRange) -> AnyTextAttachment? { @@ -165,44 +161,60 @@ class FoldingRibbonView: NSView { let pointInView = convert(event.locationInWindow, from: nil) guard let lineNumber = model?.controller?.textView.layoutManager.textLineForPosition(pointInView.y)?.index, - let fold = model?.getCachedFoldAt(lineNumber: lineNumber) else { - hoverAnimationProgress = 0.0 - hoveringFold = nil + let fold = model?.getCachedFoldAt(lineNumber: lineNumber), + !fold.isCollapsed else { + clearHoveredFold() return } - guard fold.range != hoveringFold?.range else { + guard fold.range != hoveringFold.fold?.range else { return } - hoverAnimationTimer?.invalidate() + + setHoveredFold(fold: fold) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + clearHoveredFold() + } + + /// Clears the current hovered fold. Does not animate. + func clearHoveredFold() { + hoveringFold = .empty + model?.clearEmphasis() + } + + /// Set the current hovered fold. This method determines when an animation is required and will facilitate it. + /// - Parameter fold: The fold to set as the current hovered fold. + func setHoveredFold(fold: FoldRange) { + defer { + model?.emphasizeBracketsForFold(fold) + } + + hoveringFold.timer?.invalidate() // We only animate the first hovered fold. If the user moves the mouse vertically into other folds we just // show it immediately. - if hoveringFold == nil { - hoverAnimationProgress = 0.0 - hoveringFold = fold - + if hoveringFold.fold == nil { let duration: TimeInterval = 0.2 let startTime = CACurrentMediaTime() - hoverAnimationTimer = Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [weak self] timer in - guard let self = self else { return } - let now = CACurrentMediaTime() - let time = CGFloat((now - startTime) / duration) - self.hoverAnimationProgress = min(1.0, time) - if self.hoverAnimationProgress >= 1.0 { - timer.invalidate() + + hoveringFold = HoverAnimationDetails( + fold: fold, + timer: Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [weak self] timer in + guard let self = self else { return } + let now = CACurrentMediaTime() + let time = CGFloat((now - startTime) / duration) + self.hoveringFold.progress = min(1.0, time) + if self.hoveringFold.progress >= 1.0 { + timer.invalidate() + } } - } + ) return } // Don't animate these - hoverAnimationProgress = 1.0 - hoveringFold = fold - } - - override func mouseExited(with event: NSEvent) { - super.mouseExited(with: event) - hoverAnimationProgress = 0.0 - hoveringFold = nil + hoveringFold = HoverAnimationDetails(fold: fold, progress: 1.0) } } diff --git a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift index 164cf1ad2..d41c0e478 100644 --- a/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift +++ b/Sources/CodeEditSourceEditor/RangeStore/RangeStore.swift @@ -110,7 +110,10 @@ extension RangeStore { newLength = editedRange.length } - storageUpdated(replacedCharactersIn: storageRange, withCount: newLength) + storageUpdated( + replacedCharactersIn: storageRange.clamped(to: 0..<_guts.count(in: OffsetMetric())), + withCount: newLength + ) } /// Handles keeping the internal storage in sync with the document. From ea839d673dd4a12be87613dca59e8e1f971bf107 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:23:52 -0500 Subject: [PATCH 05/14] Finish Merge from Main --- .../TextViewController+Lifecycle.swift | 14 ++++++-- .../Controller/TextViewController.swift | 35 +++++-------------- .../Gutter/GutterView.swift | 4 +-- .../Model/LineFoldCalculator.swift | 2 +- .../LineFolding/Model/LineFoldingModel.swift | 1 - ...ourceEditorConfiguration+Peripherals.swift | 9 +++++ 6 files changed, 32 insertions(+), 33 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index 824250575..e33ac4213 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -16,6 +16,16 @@ extension TextViewController { minimapView.layout() } + override public func viewDidAppear() { + super.viewDidAppear() + textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) } + } + + override public func viewDidDisappear() { + super.viewDidDisappear() + textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) } + } + override public func loadView() { super.loadView() @@ -24,7 +34,7 @@ extension TextViewController { gutterView = GutterView( configuration: configuration, - textView: textView, + controller: self, delegate: self ) gutterView.updateWidthIfNeeded() @@ -136,7 +146,7 @@ extension TextViewController { 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?.gutterView.foldingRibbon.needsDisplay = true + self.gutterView.foldingRibbon.needsDisplay = true self.reformattingGuideView?.updatePosition(in: self) self.scrollView.needsLayout = true } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 812073370..adcc91135 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -48,6 +48,9 @@ public class TextViewController: NSViewController { var localEvenMonitor: Any? var isPostingCursorNotification: Bool = false + /// A default `NSParagraphStyle` with a set `lineHeight` + lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + // MARK: - Public Variables /// Passthrough value for the `textView`s string @@ -170,21 +173,21 @@ 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: font]).width } + var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } /// Filters used when applying edits.. - internal var textFilters: [TextFormation.Filter] = [] + var textFilters: [TextFormation.Filter] = [] - internal var cancellables = Set() + var cancellables = Set() /// The trailing inset for the editor. Grows when line wrapping is disabled or when the minimap is shown. - package var textViewTrailingInset: CGFloat { + var textViewTrailingInset: CGFloat { // See https://github.com/CodeEditApp/CodeEditTextView/issues/66 // wrapLines ? 1 : 48 (minimapView?.isHidden ?? false) ? 0 : (minimapView?.frame.width ?? 0.0) } - package var textViewInsets: HorizontalEdgeInsets { + var textViewInsets: HorizontalEdgeInsets { HorizontalEdgeInsets( left: showGutter ? gutterView.frame.width : 0.0, right: textViewTrailingInset @@ -249,28 +252,6 @@ public class TextViewController: NSViewController { self.gutterView.setNeedsDisplay(self.gutterView.frame) } - // MARK: Paragraph Style - - /// A default `NSParagraphStyle` with a set `lineHeight` - package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() - - override public func viewWillAppear() { - super.viewWillAppear() - // The calculation this causes cannot be done until the view knows it's final position - updateTextInsets() - minimapView.layout() - } - - override public func viewDidAppear() { - super.viewDidAppear() - textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) } - } - - override public func viewDidDisappear() { - super.viewDidDisappear() - textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) } - } - deinit { if let highlighter { textView.removeStorageDelegate(highlighter) diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 08e8a882f..39b9874a8 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -135,14 +135,14 @@ public class GutterView: NSView { public convenience init( configuration: borrowing SourceEditorConfiguration, - textView: TextView, + controller: TextViewController, delegate: GutterViewDelegate? = nil ) { self.init( font: configuration.appearance.font, textColor: configuration.appearance.theme.text.color, selectedTextColor: configuration.appearance.theme.selection, - textView: textView, + controller: controller, delegate: delegate ) } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 92cabb8c9..e8f4872d8 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -61,7 +61,7 @@ actor LineFoldCalculator { // Depth: Open range var openFolds: [Int: LineFoldStorage.RawFold] = [:] var currentDepth: Int = 0 - var lineIterator = await ChunkedLineIterator( + let lineIterator = await ChunkedLineIterator( controller: controller, foldProvider: foldProvider, textIterator: await controller.textView.layoutManager.lineStorage.makeIterator() diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index 53b00cc9a..79ba9a71d 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -21,7 +21,6 @@ import Combine class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { static let emphasisId = "lineFolding" - /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` /// and ``FoldRange/subFolds``. @Published var foldCache: LineFoldStorage = LineFoldStorage(documentLength: 0) diff --git a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index 7385271a0..b77cc719f 100644 --- a/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift +++ b/Sources/CodeEditSourceEditor/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -16,6 +16,9 @@ extension SourceEditorConfiguration { /// Whether to show the reformatting guide. public var showReformattingGuide: Bool + /// Whether to show the folding ribbon. Only available if ``showGutter`` is `true`. + public var showFoldingRibbon: Bool + /// Configuration for drawing invisible characters. /// /// See ``InvisibleCharactersConfiguration`` for more details. @@ -29,12 +32,14 @@ extension SourceEditorConfiguration { showGutter: Bool = true, showMinimap: Bool = true, showReformattingGuide: Bool = false, + showFoldingRibbon: Bool = true, invisibleCharactersConfiguration: InvisibleCharactersConfiguration = .empty, warningCharacters: Set = [] ) { self.showGutter = showGutter self.showMinimap = showMinimap self.showReformattingGuide = showReformattingGuide + self.showFoldingRibbon = showFoldingRibbon self.invisibleCharactersConfiguration = invisibleCharactersConfiguration self.warningCharacters = warningCharacters } @@ -58,6 +63,10 @@ extension SourceEditorConfiguration { controller.reformattingGuideView.updatePosition(in: controller) } + if oldConfig?.showFoldingRibbon != showFoldingRibbon { + controller.gutterView.showFoldingRibbon = showFoldingRibbon + } + if oldConfig?.invisibleCharactersConfiguration != invisibleCharactersConfiguration { controller.invisibleCharactersCoordinator.configuration = invisibleCharactersConfiguration } From cf5dbdd25c2b5fe9f20e2b5845b84ad65e96731a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:27:13 -0500 Subject: [PATCH 06/14] lint:fix --- .../Controller/TextViewControllerTests.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index 8fe554c77..03712b8bf 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -208,7 +208,10 @@ final class TextViewControllerTests: XCTestCase { // Insert lots of spaces controller.configuration.behavior.indentOption = .spaces(count: 1000) - controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + 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)) } @@ -241,7 +244,7 @@ final class TextViewControllerTests: XCTestCase { controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() - let _ = controller.textView.becomeFirstResponder() + _ = controller.textView.becomeFirstResponder() controller.configuration.appearance.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { @@ -459,12 +462,12 @@ final class TextViewControllerTests: XCTestCase { func test_foldingRibbonToggle() { controller.setText("Hello World") - controller.showFoldingRibbon = false + controller.configuration.peripherals.showFoldingRibbon = false XCTAssertFalse(controller.gutterView.showFoldingRibbon) controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass let noRibbonWidth = controller.gutterView.frame.width - controller.showFoldingRibbon = true + controller.configuration.peripherals.showFoldingRibbon = true XCTAssertTrue(controller.gutterView.showFoldingRibbon) controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass XCTAssertEqual(controller.gutterView.frame.width, noRibbonWidth + 7.0) From 06ffead076c043bfa6a4de19f3b9fddfc2254486 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:34:57 -0500 Subject: [PATCH 07/14] Fix Test --- .../Controller/TextViewControllerTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift index 03712b8bf..c17feea24 100644 --- a/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/Controller/TextViewControllerTests.swift @@ -469,8 +469,12 @@ final class TextViewControllerTests: XCTestCase { controller.configuration.peripherals.showFoldingRibbon = true XCTAssertTrue(controller.gutterView.showFoldingRibbon) + XCTAssertFalse(controller.gutterView.foldingRibbon.isHidden) controller.gutterView.updateWidthIfNeeded() // Would be called on a display pass - XCTAssertEqual(controller.gutterView.frame.width, noRibbonWidth + 7.0) + XCTAssertEqual( + controller.gutterView.frame.width, + noRibbonWidth + 7.0 + controller.gutterView.foldingRibbonPadding + ) } // MARK: - Get Overlapping Lines From 353ada9fa49dcc92499ebb703f68103220285703 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:50:45 -0500 Subject: [PATCH 08/14] Folds in Minimap, Double Click/Enter DIsmisses Fold --- Package.swift | 2 +- .../TextViewController+StyleViews.swift | 2 +- .../Controller/TextViewController.swift | 4 +- .../Extensions/NSFont/NSFont+CharWidth.swift | 14 ++++++ .../Gutter/GutterView.swift | 2 +- .../StyledRangeContainer.swift | 5 ++- .../LineFoldProvider.swift | 4 +- .../LineIndentationFoldProvider.swift} | 5 ++- .../Model/LineFoldCalculator.swift | 11 +++-- .../LineFolding/Model/LineFoldingModel.swift | 25 ++++++++--- .../Placeholder/LineFoldPlaceholder.swift | 44 ++++++++++++------- .../LineFolding/View/FoldingRibbonView.swift | 18 +++----- .../Minimap/MinimapLineFragmentView.swift | 16 ++++++- .../Minimap/MinimapLineRenderer.swift | 2 +- ...apView+TextAttachmentManagerDelegate.swift | 30 +++++++++++++ .../Minimap/MinimapView.swift | 5 +++ .../ReformattingGuideView.swift | 2 +- 17 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+CharWidth.swift rename Sources/CodeEditSourceEditor/LineFolding/{FoldProviders => LineFoldProviders}/LineFoldProvider.swift (90%) rename Sources/CodeEditSourceEditor/LineFolding/{FoldProviders/IndentationLineFoldProvider.swift => LineFoldProviders/LineIndentationFoldProvider.swift} (91%) create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapView+TextAttachmentManagerDelegate.swift diff --git a/Package.swift b/Package.swift index 3d8026922..6b75d660c 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.11.3" + from: "0.11.4" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 3f106b010..e59e5c10e 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(tabWidth) * font.charWidth return paragraph } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index adcc91135..769b74399 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -173,7 +173,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? - var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } + var foldProvider: LineFoldProvider /// Filters used when applying edits.. var textFilters: [TextFormation.Filter] = [] @@ -202,6 +202,7 @@ public class TextViewController: NSViewController { configuration: SourceEditorConfiguration, cursorPositions: [CursorPosition], highlightProviders: [HighlightProviding] = [TreeSitterClient()], + foldProvider: LineFoldProvider? = nil, undoManager: CEUndoManager? = nil, coordinators: [TextViewCoordinator] = [] ) { @@ -209,6 +210,7 @@ public class TextViewController: NSViewController { self.configuration = configuration self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders + self.foldProvider = foldProvider ?? LineIndentationFoldProvider() self._undoManager = undoManager self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator(configuration: configuration) diff --git a/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+CharWidth.swift b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+CharWidth.swift new file mode 100644 index 000000000..606e1b681 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSFont/NSFont+CharWidth.swift @@ -0,0 +1,14 @@ +// +// NSFont+CharWidth.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/25/25. +// + +import AppKit + +extension NSFont { + var charWidth: CGFloat { + (" " as NSString).size(withAttributes: [.font: self]).width + } +} diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 39b9874a8..ab7586556 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -160,7 +160,7 @@ public class GutterView: NSView { self.textView = controller.textView self.delegate = delegate - foldingRibbon = FoldingRibbonView(controller: controller, foldProvider: nil) + foldingRibbon = FoldingRibbonView(controller: controller) super.init(frame: .zero) clipsToBounds = true diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 95c81cb7e..61a499b6f 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -96,7 +96,6 @@ class StyledRangeContainer { var runs: [RangeStoreRun] = [] var minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) - while let value = minValue { // Get minimum length off the end of each array let minRunIdx = value.offset @@ -118,7 +117,9 @@ class StyledRangeContainer { } } - allRuns[minRunIdx].removeLast() + if !allRuns[minRunIdx].isEmpty { + allRuns[minRunIdx].removeLast() + } runs.append(minRun) minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift similarity index 90% rename from Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift rename to Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift index 2e829209f..300a56d56 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/LineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift @@ -8,7 +8,7 @@ import AppKit import CodeEditTextView -enum LineFoldProviderLineInfo { +public enum LineFoldProviderLineInfo { case startFold(rangeStart: Int, newDepth: Int) case endFold(rangeEnd: Int, newDepth: Int) @@ -32,7 +32,7 @@ enum LineFoldProviderLineInfo { } @MainActor -protocol LineFoldProvider: AnyObject { +public protocol LineFoldProvider: AnyObject { func foldLevelAtLine( lineNumber: Int, lineRange: NSRange, diff --git a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineIndentationFoldProvider.swift similarity index 91% rename from Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift rename to Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineIndentationFoldProvider.swift index 84626c971..5c04b0d7d 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/FoldProviders/IndentationLineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineIndentationFoldProvider.swift @@ -1,5 +1,5 @@ // -// IndentationLineFoldProvider.swift +// LineIndentationFoldProvider.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/8/25. @@ -8,7 +8,8 @@ import AppKit import CodeEditTextView -final class IndentationLineFoldProvider: LineFoldProvider { +/// A basic fold provider that uses line indentation to determine fold regions. +final class LineIndentationFoldProvider: LineFoldProvider { func indentLevelAtLine(substring: NSString) -> Int? { for idx in 0.. + textChangedStream: AsyncStream ) { - self.foldProvider = foldProvider + self.foldProvider = controller.foldProvider self.controller = controller (valueStream, valueStreamContinuation) = AsyncStream.makeStream() Task { await listenToTextChanges(textChangedStream: textChangedStream) } @@ -42,10 +41,10 @@ actor LineFoldCalculator { /// Sets up an attached task to listen to values on a stream of text changes. /// - Parameter textChangedStream: A stream of text changes. - private func listenToTextChanges(textChangedStream: AsyncStream<(NSRange, Int)>) { + private func listenToTextChanges(textChangedStream: AsyncStream) { textChangedTask = Task { for await edit in textChangedStream { - await buildFoldsForDocument(afterEditIn: edit.0, delta: edit.1) + await buildFoldsForDocument() } } } @@ -54,7 +53,7 @@ actor LineFoldCalculator { /// /// For each line in the document, find the indentation level using the ``levelProvider``. At each line, if the /// indent increases from the previous line, we start a new fold. If it decreases we end the fold we were in. - private func buildFoldsForDocument(afterEditIn: NSRange, delta: Int) async { + private func buildFoldsForDocument() async { guard let controller = self.controller, let foldProvider = self.foldProvider else { return } let documentRange = await controller.textView.documentRange var foldCache: [LineFoldStorage.RawFold] = [] diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index 79ba9a71d..2dfe9b8e4 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -26,17 +26,18 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { @Published var foldCache: LineFoldStorage = LineFoldStorage(documentLength: 0) private var calculator: LineFoldCalculator - private var textChangedStream: AsyncStream<(NSRange, Int)> - private var textChangedStreamContinuation: AsyncStream<(NSRange, Int)>.Continuation + private var textChangedStream: AsyncStream + private var textChangedStreamContinuation: AsyncStream.Continuation private var cacheListenTask: Task? weak var controller: TextViewController? + weak var foldView: NSView? - init(controller: TextViewController, foldView: NSView, foldProvider: LineFoldProvider?) { + init(controller: TextViewController, foldView: NSView) { self.controller = controller - (textChangedStream, textChangedStreamContinuation) = AsyncStream<(NSRange, Int)>.makeStream() + self.foldView = foldView + (textChangedStream, textChangedStreamContinuation) = AsyncStream.makeStream() self.calculator = LineFoldCalculator( - foldProvider: foldProvider, controller: controller, textChangedStream: textChangedStream ) @@ -49,7 +50,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { foldView?.needsDisplay = true } } - textChangedStreamContinuation.yield((.zero, 0)) + textChangedStreamContinuation.yield(Void()) } func getFolds(in range: Range) -> [FoldRange] { @@ -66,7 +67,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { return } foldCache.storageUpdated(editedRange: editedRange, changeInLength: delta) - textChangedStreamContinuation.yield((editedRange, delta)) + textChangedStreamContinuation.yield() } /// Finds the deepest cached depth of the fold for a line number. @@ -126,3 +127,13 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { controller?.textView.emphasisManager?.removeEmphases(for: Self.emphasisId) } } + +// MARK: - LineFoldPlaceholderDelegate + +extension LineFoldingModel: LineFoldPlaceholderDelegate { + func placeholderDiscarded(fold: FoldRange) { + foldCache.toggleCollapse(forFold: fold) + foldView?.needsDisplay = true + textChangedStreamContinuation.yield() + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift index a796376aa..6fc2e9ae9 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -8,44 +8,58 @@ import AppKit import CodeEditTextView +protocol LineFoldPlaceholderDelegate: AnyObject { + func placeholderDiscarded(fold: FoldRange) +} + class LineFoldPlaceholder: TextAttachment { let fold: FoldRange let charWidth: CGFloat var isSelected: Bool = false + weak var delegate: LineFoldPlaceholderDelegate? - init(fold: FoldRange, charWidth: CGFloat) { + init(delegate: LineFoldPlaceholderDelegate?, fold: FoldRange, charWidth: CGFloat) { self.fold = fold + self.delegate = delegate self.charWidth = charWidth } var width: CGFloat { - charWidth * 5 + charWidth * 3 } func draw(in context: CGContext, rect: NSRect) { context.saveGState() - let centerY = rect.midY - 1.5 + let size = charWidth / 3.0 + let centerY = rect.midY - (size / 2.0) if isSelected { context.setFillColor(NSColor.controlAccentColor.cgColor) - context.addPath( - NSBezierPath( - rect: rect.transform(x: 2.0, y: 3.0, width: -4.0, height: -6.0 ), - roundedCorners: .all, - cornerRadius: 2 - ).cgPathFallback - ) - context.fillPath() + } else { + context.setFillColor(NSColor.tertiaryLabelColor.cgColor) } + context.addPath( + NSBezierPath( + rect: rect.transform(x: 2.0, y: 2, width: -4.0, height: -4.0 ), + roundedCorners: .all, + cornerRadius: (rect.height - 4) / 2 + ).cgPathFallback + ) + context.fillPath() + context.setFillColor(NSColor.secondaryLabelColor.cgColor) - let size = charWidth / 2 - context.addEllipse(in: CGRect(x: rect.minX + charWidth * 1.25, y: centerY, width: size, height: size)) - context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 2.25), y: centerY, width: size, height: size)) - context.addEllipse(in: CGRect(x: rect.minX + (charWidth * 3.25), y: centerY, width: size, height: size)) + context.addEllipse(in: CGRect(x: rect.minX + charWidth - (size / 2), y: centerY, width: size, height: size)) + context.addEllipse(in: CGRect(x: rect.midX - (size / 2), y: centerY, width: size, height: size)) + context.addEllipse(in: CGRect(x: rect.maxX - charWidth - (size / 2), y: centerY, width: size, height: size)) context.fillPath() context.restoreGState() } + + func attachmentAction() -> TextAttachmentAction { + delegate?.placeholderDiscarded(fold: fold) + return .discard + } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift index 54e86f4c9..440b6afe1 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView.swift @@ -26,9 +26,6 @@ class FoldingRibbonView: NSView { } } -#warning("Replace before release") - private static let demoFoldProvider = IndentationLineFoldProvider() - static let width: CGFloat = 7.0 var model: LineFoldingModel? @@ -79,16 +76,13 @@ class FoldingRibbonView: NSView { true } - init(controller: TextViewController, foldProvider: LineFoldProvider?) { + init(controller: TextViewController) { super.init(frame: .zero) layerContentsRedrawPolicy = .onSetNeedsDisplay clipsToBounds = false - - #warning("Replace before release") self.model = LineFoldingModel( controller: controller, - foldView: self, - foldProvider: foldProvider ?? Self.demoFoldProvider + foldView: self ) } @@ -114,8 +108,6 @@ class FoldingRibbonView: NSView { addTrackingArea(area) } - var attachments: [LineFoldPlaceholder] = [] - override func scrollWheel(with event: NSEvent) { super.scrollWheel(with: event) self.mouseMoved(with: event) @@ -134,15 +126,15 @@ class FoldingRibbonView: NSView { if let attachment = findAttachmentFor(fold: fold, firstLineRange: firstLineInFold.range) { layoutManager.attachments.remove(atOffset: attachment.range.location) - attachments.removeAll(where: { $0 === attachment.attachment }) } else { - let placeholder = LineFoldPlaceholder(fold: fold, charWidth: model?.controller?.fontCharWidth ?? 1.0) + let charWidth = model?.controller?.font.charWidth ?? 1.0 + let placeholder = LineFoldPlaceholder(delegate: model, fold: fold, charWidth: charWidth) layoutManager.attachments.add(placeholder, for: NSRange(fold.range)) - attachments.append(placeholder) } model?.foldCache.toggleCollapse(forFold: fold) model?.controller?.textView.needsLayout = true + model?.controller?.gutterView.needsDisplay = true mouseMoved(with: event) } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift index 02ac3c2bc..e148a0e76 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -50,7 +50,19 @@ final class MinimapLineFragmentView: LineFragmentView { // Create the drawing runs using attribute information var position = newFragment.documentRange.location - while position < newFragment.documentRange.max { + for contentRun in newFragment.contents { + switch contentRun.data { + case .text: + addDrawingRunsUntil(max: position + contentRun.length, position: &position, textStorage: textStorage) + case .attachment(let attachment): + position += attachment.range.length + appendDrawingRun(color: .clear, range: NSRange(location: position, length: contentRun.length)) + } + } + } + + private func addDrawingRunsUntil(max: Int, position: inout Int, textStorage: NSTextStorage) { + while position < max { var longestRange: NSRange = .notFound defer { position = longestRange.max } @@ -58,7 +70,7 @@ final class MinimapLineFragmentView: LineFragmentView { .foregroundColor, at: position, longestEffectiveRange: &longestRange, - in: NSRange(start: position, end: newFragment.documentRange.max) + in: NSRange(start: position, end: max) ) as? NSColor else { continue } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index 0a5a050f3..e6366c768 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -34,7 +34,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { range: range, stringRef: stringRef, markedRanges: markedRanges, - attachments: [] + attachments: attachments ) // Make all fragments 2px tall diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextAttachmentManagerDelegate.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextAttachmentManagerDelegate.swift new file mode 100644 index 000000000..1b0794230 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextAttachmentManagerDelegate.swift @@ -0,0 +1,30 @@ +// +// MinimapView+TextAttachmentManagerDelegate.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/25/25. +// + +import AppKit +import CodeEditTextView + +extension MinimapView: TextAttachmentManagerDelegate { + class MinimapAttachment: TextAttachment { + var isSelected: Bool = false + var width: CGFloat + + init(_ other: TextAttachment, widthRatio: CGFloat) { + self.width = other.width * widthRatio + } + + func draw(in context: CGContext, rect: NSRect) { } + } + + public func textAttachmentDidAdd(_ attachment: TextAttachment, for range: NSRange) { + layoutManager?.attachments.add(MinimapAttachment(attachment, widthRatio: editorToMinimapWidthRatio), for: range) + } + + public func textAttachmentDidRemove(_ attachment: TextAttachment, for range: NSRange) { + layoutManager?.attachments.remove(atOffset: range.location) + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index d515d94d1..afd97dee7 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -63,6 +63,10 @@ public class MinimapView: FlippedNSView { minimapHeight / editorHeight } + var editorToMinimapWidthRatio: CGFloat { + 3.0 / (textView?.font.charWidth ?? 3.0) + } + /// The height of the available container, less the scroll insets to reflect the visible height. var containerHeight: CGFloat { scrollView.visibleRect.height - scrollView.contentInsets.vertical @@ -144,6 +148,7 @@ public class MinimapView: FlippedNSView { delegate: self, renderDelegate: lineRenderer ) + textView.layoutManager.attachments.delegate = self self.layoutManager = layoutManager self.contentView.layoutManager = layoutManager (textView.textStorage.delegate as? MultiStorageDelegate)?.addDelegate(layoutManager) diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift index e4033781e..6c58b8dfd 100644 --- a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -77,7 +77,7 @@ class ReformattingGuideView: NSView { func updatePosition(in controller: TextViewController) { // Calculate the x position based on the font's character width and column number let xPosition = ( - CGFloat(column) * (controller.fontCharWidth / 2) // Divide by 2 to account for coordinate system + CGFloat(column) * (controller.font.charWidth / 2) // Divide by 2 to account for coordinate system + (controller.textViewInsets.left / 2) ) From 506504e30f5494ba04f2f9f60b003977ac42c9f6 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:11:43 -0500 Subject: [PATCH 09/14] Final Placeholder Design Iteration --- .../LineFolding/Model/LineFoldingModel.swift | 16 ++++++++ .../Placeholder/LineFoldPlaceholder.swift | 38 ++++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift index 2dfe9b8e4..42b232614 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift @@ -131,6 +131,22 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { // MARK: - LineFoldPlaceholderDelegate extension LineFoldingModel: LineFoldPlaceholderDelegate { + func placeholderBackgroundColor() -> NSColor { + controller?.configuration.appearance.theme.invisibles.color ?? .lightGray + } + + func placeholderTextColor() -> NSColor { + controller?.configuration.appearance.theme.text.color.withAlphaComponent(0.35) ?? .tertiaryLabelColor + } + + func placeholderSelectedColor() -> NSColor { + .controlAccentColor + } + + func placeholderSelectedTextColor() -> NSColor { + controller?.theme.background ?? .controlBackgroundColor + } + func placeholderDiscarded(fold: FoldRange) { foldCache.toggleCollapse(forFold: fold) foldView?.needsDisplay = true diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift index 6fc2e9ae9..246e53550 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -9,6 +9,12 @@ import AppKit import CodeEditTextView protocol LineFoldPlaceholderDelegate: AnyObject { + func placeholderBackgroundColor() -> NSColor + func placeholderTextColor() -> NSColor + + func placeholderSelectedColor() -> NSColor + func placeholderSelectedTextColor() -> NSColor + func placeholderDiscarded(fold: FoldRange) } @@ -25,34 +31,46 @@ class LineFoldPlaceholder: TextAttachment { } var width: CGFloat { - charWidth * 3 + charWidth * 5 } func draw(in context: CGContext, rect: NSRect) { context.saveGState() - let size = charWidth / 3.0 + guard let delegate else { return } + + let size = charWidth / 2.5 let centerY = rect.midY - (size / 2.0) if isSelected { - context.setFillColor(NSColor.controlAccentColor.cgColor) + context.setFillColor(delegate.placeholderSelectedColor().cgColor) } else { - context.setFillColor(NSColor.tertiaryLabelColor.cgColor) + context.setFillColor(delegate.placeholderBackgroundColor().cgColor) } context.addPath( NSBezierPath( - rect: rect.transform(x: 2.0, y: 2, width: -4.0, height: -4.0 ), + rect: rect.transform(x: charWidth, y: 2.0, width: -charWidth * 2, height: -4.0), roundedCorners: .all, - cornerRadius: (rect.height - 4) / 2 + cornerRadius: rect.height / 2 ).cgPathFallback ) context.fillPath() - context.setFillColor(NSColor.secondaryLabelColor.cgColor) - context.addEllipse(in: CGRect(x: rect.minX + charWidth - (size / 2), y: centerY, width: size, height: size)) - context.addEllipse(in: CGRect(x: rect.midX - (size / 2), y: centerY, width: size, height: size)) - context.addEllipse(in: CGRect(x: rect.maxX - charWidth - (size / 2), y: centerY, width: size, height: size)) + if isSelected { + context.setFillColor(delegate.placeholderSelectedTextColor().cgColor) + } else { + context.setFillColor(delegate.placeholderTextColor().cgColor) + } + context.addEllipse( + in: CGRect(x: rect.minX + (charWidth * 2) - size, y: centerY, width: size, height: size) + ) + context.addEllipse( + in: CGRect(x: rect.midX - (size / 2), y: centerY, width: size, height: size) + ) + context.addEllipse( + in: CGRect(x: rect.maxX - (charWidth * 2), y: centerY, width: size, height: size) + ) context.fillPath() context.restoreGState() From f340004e0fa6230ea840700615be94295c36c261 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:11:59 -0500 Subject: [PATCH 10/14] lint:fix --- .../LineFolding/Placeholder/LineFoldPlaceholder.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift index 246e53550..219a63346 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -11,7 +11,7 @@ import CodeEditTextView protocol LineFoldPlaceholderDelegate: AnyObject { func placeholderBackgroundColor() -> NSColor func placeholderTextColor() -> NSColor - + func placeholderSelectedColor() -> NSColor func placeholderSelectedTextColor() -> NSColor From 0996a5d978c88f98585bcdc2e201c849a6755770 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:15:16 -0500 Subject: [PATCH 11/14] Fix Tests --- Package.resolved | 4 ++-- .../LineFoldingTests/LineFoldingModelTests.swift | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 511f99034..954e31f14 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "df485cb63e163c9bdc68ec0617c113d301368da6", - "version" : "0.11.3" + "revision" : "8f02a6b206091ee4aaee9006e2ef1ddc68e754c8", + "version" : "0.11.4" } }, { diff --git a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift index 168feab50..8084f5b1f 100644 --- a/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift +++ b/Tests/CodeEditSourceEditorTests/LineFoldingTests/LineFoldingModelTests.swift @@ -55,7 +55,8 @@ struct LineFoldingModelTests { @Test func buildFoldsForDocument() async throws { let provider = HillPatternFoldProvider() - let model = LineFoldingModel(controller: controller, foldView: NSView(), foldProvider: provider) + controller.foldProvider = provider + let model = LineFoldingModel(controller: controller, foldView: NSView()) var cacheUpdated = model.$foldCache.values.makeAsyncIterator() _ = await cacheUpdated.next() From cb352fb7d3af4a6fe334314f43fb796a4dac1576 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:53:45 -0500 Subject: [PATCH 12/14] Revert Change --- .../StyledRangeContainer/StyledRangeContainer.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift index 61a499b6f..95c81cb7e 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/StyledRangeContainer/StyledRangeContainer.swift @@ -96,6 +96,7 @@ class StyledRangeContainer { var runs: [RangeStoreRun] = [] var minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) + while let value = minValue { // Get minimum length off the end of each array let minRunIdx = value.offset @@ -117,9 +118,7 @@ class StyledRangeContainer { } } - if !allRuns[minRunIdx].isEmpty { - allRuns[minRunIdx].removeLast() - } + allRuns[minRunIdx].removeLast() runs.append(minRun) minValue = allRuns.compactMap { $0.last }.enumerated().min(by: { $0.1.length < $1.1.length }) From e341e0b0a1bfa4caf8d9fa626f1164892c706a43 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:35:21 -0500 Subject: [PATCH 13/14] Naming Consistency, Documentation --- .../Gutter/GutterView.swift | 6 +++--- .../LineFoldProviders/LineFoldProvider.swift | 9 +++++++++ .../LineFolding/Model/FoldRange.swift | 20 +++++++++++++++++++ .../Model/LineFoldCalculator.swift | 11 ++++++---- ...FoldingModel.swift => LineFoldModel.swift} | 20 +++++++++---------- .../LineFolding/Model/LineFoldStorage.swift | 14 ------------- .../Placeholder/LineFoldPlaceholder.swift | 4 ++++ ...aw.swift => LineFoldRibbonView+Draw.swift} | 7 +++++-- ...t => LineFoldRibbonView+FoldCapInfo.swift} | 4 ++-- ...bonView.swift => LineFoldRibbonView.swift} | 15 +++++++++----- .../LineFoldingModelTests.swift | 2 +- 11 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift rename Sources/CodeEditSourceEditor/LineFolding/Model/{LineFoldingModel.swift => LineFoldModel.swift} (88%) rename Sources/CodeEditSourceEditor/LineFolding/View/{FoldingRibbonView+Draw.swift => LineFoldRibbonView+Draw.swift} (97%) rename Sources/CodeEditSourceEditor/LineFolding/View/{FoldingRibbonView+FoldCapInfo.swift => LineFoldRibbonView+FoldCapInfo.swift} (98%) rename Sources/CodeEditSourceEditor/LineFolding/View/{FoldingRibbonView.swift => LineFoldRibbonView.swift} (92%) diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index ab7586556..8be57ec62 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -101,14 +101,14 @@ public class GutterView: NSView { } /// The view that draws the fold decoration in the gutter. - var foldingRibbon: FoldingRibbonView + var foldingRibbon: LineFoldRibbonView /// Syntax helper for determining the required space for the folding ribbon. private var foldingRibbonWidth: CGFloat { if foldingRibbon.isHidden { 0.0 } else { - FoldingRibbonView.width + foldingRibbonPadding + LineFoldRibbonView.width + foldingRibbonPadding } } @@ -160,7 +160,7 @@ public class GutterView: NSView { self.textView = controller.textView self.delegate = delegate - foldingRibbon = FoldingRibbonView(controller: controller) + foldingRibbon = LineFoldRibbonView(controller: controller) super.init(frame: .zero) clipsToBounds = true diff --git a/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift index 300a56d56..16efaea3e 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift @@ -8,6 +8,7 @@ import AppKit import CodeEditTextView +/// Represents a fold's start or end. public enum LineFoldProviderLineInfo { case startFold(rangeStart: Int, newDepth: Int) case endFold(rangeEnd: Int, newDepth: Int) @@ -31,6 +32,14 @@ public enum LineFoldProviderLineInfo { } } +/// ``LineFoldProvider`` is an interface used by the editor to find fold regions in a document. +/// +/// The only required method, ``LineFoldProvider/foldLevelAtLine(lineNumber:lineRange:previousDepth:controller:)``, +/// will be called very often. Return as fast as possible from this method, keeping in mind it is taking time on the +/// main thread. +/// +/// Ordering between calls is not guaranteed, the provider may restart at any time. The implementation should provide fold info +/// for only the given lines. @MainActor public protocol LineFoldProvider: AnyObject { func foldLevelAtLine( diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift new file mode 100644 index 000000000..aa6c9ace8 --- /dev/null +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift @@ -0,0 +1,20 @@ +// +// FoldRange.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 6/26/25. +// + +/// Represents a single fold region with stable identifier and collapse state +struct FoldRange: Sendable, Equatable { + typealias FoldIdentifier = UInt32 + + let id: FoldIdentifier + let depth: Int + let range: Range + var isCollapsed: Bool + + func isHoveringEqual(_ other: FoldRange) -> Bool { + depth == other.depth && range.contains(other.range) + } +} diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 607959c72..939d27dc3 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -8,9 +8,10 @@ import AppKit import CodeEditTextView -/// A utility that calculates foldable line ranges in a text document based on indentation depth. +/// `LineFoldCalculator` receives text edits and rebuilds fold regions asynchronously. /// -/// `LineFoldCalculator` observes text edits and rebuilds fold regions asynchronously. +/// This is an actor, all methods and modifications happen in isolation in it's async region. All text requests are +/// marked `@MainActor` for safety. actor LineFoldCalculator { weak var foldProvider: LineFoldProvider? weak var controller: TextViewController? @@ -26,10 +27,12 @@ actor LineFoldCalculator { /// - controller: The text controller to use for text and attachment fetching. /// - textChangedStream: A stream of text changes, received as the document is edited. init( + foldProvider: LineFoldProvider, controller: TextViewController, textChangedStream: AsyncStream ) { - self.foldProvider = controller.foldProvider + // This could be grabbed from the controller, but Swift 6 doesn't like that (concurrency safety) + self.foldProvider = foldProvider self.controller = controller (valueStream, valueStreamContinuation) = AsyncStream.makeStream() Task { await listenToTextChanges(textChangedStream: textChangedStream) } @@ -43,7 +46,7 @@ actor LineFoldCalculator { /// - Parameter textChangedStream: A stream of text changes. private func listenToTextChanges(textChangedStream: AsyncStream) { textChangedTask = Task { - for await edit in textChangedStream { + for await _ in textChangedStream { await buildFoldsForDocument() } } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift similarity index 88% rename from Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift rename to Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift index 42b232614..03de8ac7d 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldingModel.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift @@ -1,5 +1,5 @@ // -// LineFoldingModel.swift +// LineFoldModel.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/7/25. @@ -9,16 +9,15 @@ import AppKit import CodeEditTextView import Combine -/// # Basic Premise +/// This object acts as the conductor between the line folding components. /// -/// We need to update, delete, or add fold ranges in the invalidated lines. +/// This receives text changed events, and notifies the line fold calculator. +/// It then receives fold calculation updates, and notifies the drawing view. +/// It manages a cache of fold ranges for drawing. /// -/// # Implementation -/// -/// - For each line in the document, put its indent level into a list. -/// - Loop through the list, creating nested folds as indents go up and down. -/// -class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { +/// For fold storage and querying, see ``LineFoldStorage``. For fold calculation see ``LineFoldCalculator`` +/// and ``LineFoldProvider``. For drawing see ``LineFoldRibbonView``. +class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { static let emphasisId = "lineFolding" /// An ordered tree of fold ranges in a document. Can be traversed using ``FoldRange/parent`` @@ -38,6 +37,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { self.foldView = foldView (textChangedStream, textChangedStreamContinuation) = AsyncStream.makeStream() self.calculator = LineFoldCalculator( + foldProvider: controller.foldProvider, controller: controller, textChangedStream: textChangedStream ) @@ -130,7 +130,7 @@ class LineFoldingModel: NSObject, NSTextStorageDelegate, ObservableObject { // MARK: - LineFoldPlaceholderDelegate -extension LineFoldingModel: LineFoldPlaceholderDelegate { +extension LineFoldModel: LineFoldPlaceholderDelegate { func placeholderBackgroundColor() -> NSColor { controller?.configuration.appearance.theme.invisibles.color ?? .lightGray } diff --git a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift index 52b5d78dd..b734be850 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -7,20 +7,6 @@ import _RopeModule import Foundation -/// Represents a single fold region with stable identifier and collapse state -struct FoldRange: Sendable, Equatable { - typealias FoldIdentifier = UInt32 - - let id: FoldIdentifier - let depth: Int - let range: Range - var isCollapsed: Bool - - func isHoveringEqual(_ other: FoldRange) -> Bool { - depth == other.depth && range.contains(other.range) - } -} - /// Sendable data model for code folding using RangeStore struct LineFoldStorage: Sendable { /// A temporary fold representation without stable ID diff --git a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift index 219a63346..67758cf45 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/Placeholder/LineFoldPlaceholder.swift @@ -18,6 +18,10 @@ protocol LineFoldPlaceholderDelegate: AnyObject { func placeholderDiscarded(fold: FoldRange) } +/// Used to display a folded region in a text document. +/// +/// To stay up-to-date with the user's theme, it uses the ``LineFoldPlaceholderDelegate`` to query for current colors +/// to use for drawing. class LineFoldPlaceholder: TextAttachment { let fold: FoldRange let charWidth: CGFloat diff --git a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift b/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift similarity index 97% rename from Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift rename to Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift index 3680d832f..581379369 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/View/FoldingRibbonView+Draw.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift @@ -1,5 +1,5 @@ // -// FoldingRibbonView.swift +// LineFoldRibbonView+Draw.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/8/25. @@ -8,7 +8,7 @@ import AppKit import CodeEditTextView -extension FoldingRibbonView { +extension LineFoldRibbonView { struct DrawingFoldInfo { let fold: FoldRange let startLine: TextLineStorage.TextLinePosition @@ -29,12 +29,14 @@ extension FoldingRibbonView { context.saveGState() context.clip(to: dirtyRect) + // Only draw folds in the requested dirty rect let folds = getDrawingFolds( forTextRange: rangeStart.range.location.. Date: Thu, 26 Jun 2025 10:55:17 -0500 Subject: [PATCH 14/14] lint:fix --- .../LineFolding/LineFoldProviders/LineFoldProvider.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift index 16efaea3e..6ffbd5adf 100644 --- a/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift +++ b/Sources/CodeEditSourceEditor/LineFolding/LineFoldProviders/LineFoldProvider.swift @@ -38,8 +38,8 @@ public enum LineFoldProviderLineInfo { /// will be called very often. Return as fast as possible from this method, keeping in mind it is taking time on the /// main thread. /// -/// Ordering between calls is not guaranteed, the provider may restart at any time. The implementation should provide fold info -/// for only the given lines. +/// Ordering between calls is not guaranteed, the provider may restart at any time. The implementation should provide +/// fold info for only the given lines. @MainActor public protocol LineFoldProvider: AnyObject { func foldLevelAtLine(