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 ) }