From a10ba6f47417254e8aa507c2b9541be5158515c2 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Mon, 10 Mar 2025 19:50:28 +0100 Subject: [PATCH 01/37] Add search manager and search field --- .../project.pbxproj | 14 +++ .../TextViewController+LoadView.swift | 61 +++++++++- .../Controller/TextViewController.swift | 8 +- .../SearchManager/SearchManager.swift | 110 ++++++++++++++++++ 4 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 2ce04dadc..328c585b6 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61621C602C74FB2200494A4A /* CodeEditSourceEditor */; }; + 61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */; }; + 61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */; }; 6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652D2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift */; }; 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652F2B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift */; }; 6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365312B8A7B94004A1D18 /* ContentView.swift */; }; @@ -41,6 +43,8 @@ buildActionMask = 2147483647; files = ( 61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */, + 61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */, + 61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -140,6 +144,8 @@ name = CodeEditSourceEditorExample; packageProductDependencies = ( 61621C602C74FB2200494A4A /* CodeEditSourceEditor */, + 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */, + 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */, ); productName = CodeEditSourceEditorExample; productReference = 6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */; @@ -412,6 +418,14 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; + 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6C1365222B8A7B94004A1D18 /* Project object */; diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index fab08cf44..daf1bcecd 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -11,10 +11,15 @@ import AppKit extension TextViewController { // swiftlint:disable:next function_body_length override public func loadView() { + let stackView = NSStackView() + stackView.orientation = .vertical + stackView.spacing = 10 + stackView.alignment = .leading + stackView.translatesAutoresizingMaskIntoConstraints = false + scrollView = NSScrollView() textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false - scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.contentView.postsFrameChangedNotifications = true scrollView.hasVerticalScroller = true @@ -34,7 +39,46 @@ extension TextViewController { for: .horizontal ) - self.view = scrollView + searchField = NSTextField() + searchField.placeholderString = "Search..." + searchField.controlSize = .regular // TODO: a + searchField.focusRingType = .none + searchField.bezelStyle = .roundedBezel + searchField.drawsBackground = true + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.action = #selector(onSubmit) + searchField.target = self + + prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) + prevButton.bezelStyle = .texturedRounded + prevButton.controlSize = .small + prevButton.translatesAutoresizingMaskIntoConstraints = false + + nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) + nextButton.bezelStyle = .texturedRounded + nextButton.controlSize = .small + nextButton.translatesAutoresizingMaskIntoConstraints = false + + stackview = NSStackView() + stackview.orientation = .horizontal + stackview.spacing = 8 + stackview.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) + stackview.translatesAutoresizingMaskIntoConstraints = false + + stackview.addView(searchField, in: .leading) + stackview.addView(prevButton, in: .trailing) + stackview.addView(nextButton, in: .trailing) + + NotificationCenter.default.addObserver( + self, + selector: #selector(searchFieldUpdated(_:)), + name: NSControl.textDidChangeNotification, + object: searchField + ) + + stackView.addArrangedSubview(stackview) + stackView.addArrangedSubview(scrollView) + self.view = stackView if let _undoManager { textView.setUndoManager(_undoManager) } @@ -46,10 +90,15 @@ extension TextViewController { setUpTextFormation() NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + + // scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// scrollView.topAnchor.constraint(equalTo: view.topAnchor), +// scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) if !cursorPositions.isEmpty { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 4483fece6..341fda42c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -21,9 +21,15 @@ public class TextViewController: NSViewController { public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") var scrollView: NSScrollView! + // SEARCH + var stackview: NSStackView! + var searchField: NSTextField! + var prevButton: NSButton! + var nextButton: NSButton! + // SEARCH var textView: TextView! var gutterView: GutterView! - internal var _undoManager: CEUndoManager? + internal var _undoManager: CEUndoManager! /// Internal reference to any injected layers in the text view. internal var highlightLayers: [CALayer] = [] internal var systemAppearance: NSAppearance.Name? diff --git a/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift b/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift new file mode 100644 index 000000000..90aae4f2d --- /dev/null +++ b/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift @@ -0,0 +1,110 @@ +// +// SearchManager.swift +// CodeEditSourceEditor +// +// Created by Tommy Ludwig on 03.02.25. +// + +import AppKit +import CodeEditTextView + +extension TextViewController { + @objc func searchFieldUpdated(_ notification: Notification) { + if let textField = notification.object as? NSTextField { + searchFile(query: textField.stringValue) + } + } + + @objc func onSubmit() { + if let highlightedRange = textView.emphasizeAPI?.emphasizedRanges[textView.emphasizeAPI?.emphasizedRangeIndex ?? 0] { + setCursorPositions([CursorPosition(range: highlightedRange.range)]) + updateCursorPosition() + } + } + + @objc func prevButtonClicked() { + textView?.emphasizeAPI?.highlightPrevious() + if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + textView.scrollToRange(currentRange) + } + } + + @objc func nextButtonClicked() { + textView?.emphasizeAPI?.highlightNext() + if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + textView.scrollToRange(currentRange) + self.gutterView.needsDisplay = true + } + } + + func searchFile(query: String) { + let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: query) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions) else { + textView?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + guard !matches.isEmpty else { + textView?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let searchResults = matches.map { $0.range } + let bestHighlightIndex = getNearestHighlightIndex(ranges: searchResults) ?? 0 + print(searchResults.count) + textView?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: bestHighlightIndex) + cursorPositions = [CursorPosition(range: searchResults[bestHighlightIndex])] + } + + private func getNearestHighlightIndex(ranges: [NSRange]) -> Int? { + // order the array as follows + // Found: 1 -> 2 -> 3 -> 4 + // Cursor: | + // Result: 3 -> 4 -> 1 -> 2 + guard let cursorPosition = cursorPositions.first else { return nil } + let start = cursorPosition.range.location + + var left = 0 + var right = ranges.count - 1 + var bestIndex = -1 + var bestDiff = Int.max // Stores the closest difference + + while left <= right { + let mid = left + (right - left) / 2 + let midStart = ranges[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-serach the part of the file that changed upwards + private func reSearch() { } + + // Returns true if string contains uppercase letter + // used for: ignores letter case if the search query is all lowercase + private func smartCase(str: String) -> Bool { + return str.range(of: "[A-Z]", options: .regularExpression) != nil + } +} From d920eb67fb6437b8d097b2013ba7b4295b9f57de Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 11 Mar 2025 11:07:14 -0500 Subject: [PATCH 02/37] Add Search Container, Search Bar, Show/Hide Commands, Animations --- .../xcshareddata/swiftpm/Package.resolved | 21 +- Package.resolved | 25 +- Package.swift | 2 +- .../TextViewController+LoadView.swift | 85 +++---- .../Controller/TextViewController.swift | 8 +- .../Search/SearchBar.swift | 95 ++++++++ .../Search/SearchTarget.swift | 19 ++ .../Search/SearchViewController.swift | 220 ++++++++++++++++++ .../TextViewController+SearchTarget.swift | 14 ++ .../SearchManager/SearchManager.swift | 110 --------- 10 files changed, 392 insertions(+), 207 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Search/SearchBar.swift create mode 100644 Sources/CodeEditSourceEditor/Search/SearchTarget.swift create mode 100644 Sources/CodeEditSourceEditor/Search/SearchViewController.swift create mode 100644 Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift delete mode 100644 Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8b0060e0a..aa1aeeba9 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,17 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "eb1d38247a45bc678b5a23a65d6f6df6c56519e4", - "version" : "0.7.5" - } - }, - { - "identity" : "mainoffender", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattmassicotte/MainOffender", - "state" : { - "revision" : "8de872d9256ff7f9913cbc5dd560568ab164be45", - "version" : "0.2.1" + "revision" : "1792167c751b6668b4743600d2cf73d2829dd18a", + "version" : "0.7.9" } }, { @@ -68,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextFormation", "state" : { - "revision" : "f6faed6abd768ae95b70d10113d4008a7cac57a7", - "version" : "0.8.2" + "revision" : "b1ce9a14bd86042bba4de62236028dc4ce9db6a1", + "version" : "0.9.0" } }, { @@ -77,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextStory", "state" : { - "revision" : "8883fa739aa213e70e6cb109bfbf0a0b551e4cb5", - "version" : "0.8.0" + "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", + "version" : "0.9.0" } } ], diff --git a/Package.resolved b/Package.resolved index 4b1c88c86..9495e5186 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,17 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "2619cb945b4d6c2fc13f22ba873ba891f552b0f3", - "version" : "0.7.6" - } - }, - { - "identity" : "mainoffender", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattmassicotte/MainOffender", - "state" : { - "revision" : "343cc3797618c29b48b037b4e2beea0664e75315", - "version" : "0.1.0" + "revision" : "1792167c751b6668b4743600d2cf73d2829dd18a", + "version" : "0.7.9" } }, { @@ -50,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lukepistrol/SwiftLintPlugin", "state" : { - "revision" : "f69b412a765396d44dc9f4788a5b79919c1ca9e3", - "version" : "0.2.2" + "revision" : "3825ebf8d55bb877c91bc897e8e3d0c001f16fba", + "version" : "0.58.2" } }, { @@ -68,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextFormation", "state" : { - "revision" : "f6faed6abd768ae95b70d10113d4008a7cac57a7", - "version" : "0.8.2" + "revision" : "b1ce9a14bd86042bba4de62236028dc4ce9db6a1", + "version" : "0.9.0" } }, { @@ -77,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ChimeHQ/TextStory", "state" : { - "revision" : "8883fa739aa213e70e6cb109bfbf0a0b551e4cb5", - "version" : "0.8.0" + "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", + "version" : "0.9.0" } } ], diff --git a/Package.swift b/Package.swift index 2892c2c3c..2e7c24956 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.7.6" + from: "0.7.9" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index daf1bcecd..f890583c2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -11,11 +11,7 @@ import AppKit extension TextViewController { // swiftlint:disable:next function_body_length override public func loadView() { - let stackView = NSStackView() - stackView.orientation = .vertical - stackView.spacing = 10 - stackView.alignment = .leading - stackView.translatesAutoresizingMaskIntoConstraints = false + super.loadView() scrollView = NSScrollView() textView.postsFrameChangedNotifications = true @@ -39,46 +35,12 @@ extension TextViewController { for: .horizontal ) - searchField = NSTextField() - searchField.placeholderString = "Search..." - searchField.controlSize = .regular // TODO: a - searchField.focusRingType = .none - searchField.bezelStyle = .roundedBezel - searchField.drawsBackground = true - searchField.translatesAutoresizingMaskIntoConstraints = false - searchField.action = #selector(onSubmit) - searchField.target = self - - prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) - prevButton.bezelStyle = .texturedRounded - prevButton.controlSize = .small - prevButton.translatesAutoresizingMaskIntoConstraints = false - - nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) - nextButton.bezelStyle = .texturedRounded - nextButton.controlSize = .small - nextButton.translatesAutoresizingMaskIntoConstraints = false - - stackview = NSStackView() - stackview.orientation = .horizontal - stackview.spacing = 8 - stackview.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) - stackview.translatesAutoresizingMaskIntoConstraints = false - - stackview.addView(searchField, in: .leading) - stackview.addView(prevButton, in: .trailing) - stackview.addView(nextButton, in: .trailing) + let searchController = SearchViewController(target: self, childView: scrollView) + addChild(searchController) + self.view.addSubview(searchController.view) + searchController.view.viewDidMoveToSuperview() + self.searchController = searchController - NotificationCenter.default.addObserver( - self, - selector: #selector(searchFieldUpdated(_:)), - name: NSControl.textDidChangeNotification, - object: searchField - ) - - stackView.addArrangedSubview(stackview) - stackView.addArrangedSubview(scrollView) - self.view = stackView if let _undoManager { textView.setUndoManager(_undoManager) } @@ -90,15 +52,10 @@ extension TextViewController { setUpTextFormation() NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - stackView.topAnchor.constraint(equalTo: view.topAnchor), - stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - - // scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// scrollView.topAnchor.constraint(equalTo: view.topAnchor), -// scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + searchController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + searchController.view.topAnchor.constraint(equalTo: view.topAnchor), + searchController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) if !cursorPositions.isEmpty { @@ -162,14 +119,26 @@ extension TextViewController { if let localEventMonitor = self.localEvenMonitor { NSEvent.removeMonitor(localEventMonitor) } - self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in guard self?.view.window?.firstResponder == self?.textView else { return event } - let commandKey = NSEvent.ModifierFlags.command.rawValue - let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue - if modifierFlags == commandKey && event.charactersIgnoringModifiers == "/" { + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + switch (modifierFlags, event.charactersIgnoringModifiers?.lowercased()) { + case (.command, "/"): self?.handleCommandSlash() return nil - } else { + case (.command, "f"): + _ = self?.textView.resignFirstResponder() + self?.searchController?.showSearchBar() + return nil + case ([], "\u{1b}"): // Escape key + self?.searchController?.hideSearchBar() + _ = self?.textView.becomeFirstResponder() + self?.textView.selectionManager.setSelectedRanges( + self?.textView.selectionManager.textSelections.map { $0.range } ?? [] + ) + return nil + default: return event } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 341fda42c..f8f63acb4 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -20,13 +20,9 @@ public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + weak var searchController: SearchViewController? + var scrollView: NSScrollView! - // SEARCH - var stackview: NSStackView! - var searchField: NSTextField! - var prevButton: NSButton! - var nextButton: NSButton! - // SEARCH var textView: TextView! var gutterView: GutterView! internal var _undoManager: CEUndoManager! diff --git a/Sources/CodeEditSourceEditor/Search/SearchBar.swift b/Sources/CodeEditSourceEditor/Search/SearchBar.swift new file mode 100644 index 000000000..16e15791e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/SearchBar.swift @@ -0,0 +1,95 @@ +// +// SearchBar.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import AppKit + +protocol SearchBarDelegate: AnyObject { + func searchBarOnSubmit() + func searchBarOnCancel() + func searchBarDidUpdate(_ searchText: String) + func searchBarPrevButtonClicked() + func searchBarNextButtonClicked() +} + +/// A control for searching a document and navigating results. +final class SearchBar: NSStackView { + weak var searchDelegate: SearchBarDelegate? + + var searchField: NSTextField! + var prevButton: NSButton! + var nextButton: NSButton! + + init(delegate: SearchBarDelegate?) { + super.init(frame: .zero) + + self.searchDelegate = delegate + + searchField = NSTextField() + searchField.placeholderString = "Search..." + searchField.controlSize = .regular // TODO: a + searchField.focusRingType = .none + searchField.bezelStyle = .roundedBezel + searchField.drawsBackground = true + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.action = #selector(onSubmit) + searchField.target = self + + prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) + prevButton.bezelStyle = .texturedRounded + prevButton.controlSize = .small + prevButton.translatesAutoresizingMaskIntoConstraints = false + + nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) + nextButton.bezelStyle = .texturedRounded + nextButton.controlSize = .small + nextButton.translatesAutoresizingMaskIntoConstraints = false + + self.orientation = .horizontal + self.spacing = 8 + self.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) + self.translatesAutoresizingMaskIntoConstraints = false + + self.addView(searchField, in: .leading) + self.addView(prevButton, in: .trailing) + self.addView(nextButton, in: .trailing) + + NotificationCenter.default.addObserver( + self, + selector: #selector(searchFieldUpdated(_:)), + name: NSControl.textDidChangeNotification, + object: searchField + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Hide the search bar when escape is pressed + override func cancelOperation(_ sender: Any?) { + searchDelegate?.searchBarOnCancel() + } + + // MARK: - Delegate Messaging + + @objc func searchFieldUpdated(_ notification: Notification) { + guard let searchField = notification.object as? NSTextField else { return } + searchDelegate?.searchBarDidUpdate(searchField.stringValue) + } + + @objc func onSubmit() { + searchDelegate?.searchBarOnSubmit() + } + + @objc func prevButtonClicked() { + searchDelegate?.searchBarPrevButtonClicked() + } + + @objc func nextButtonClicked() { + searchDelegate?.searchBarNextButtonClicked() + } +} diff --git a/Sources/CodeEditSourceEditor/Search/SearchTarget.swift b/Sources/CodeEditSourceEditor/Search/SearchTarget.swift new file mode 100644 index 000000000..e00682603 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/SearchTarget.swift @@ -0,0 +1,19 @@ +// +// SearchTarget.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +// This dependency is not ideal, maybe we could make this another protocol that the emphasize API conforms to similar +// to this one? +import CodeEditTextView + +protocol SearchTarget: AnyObject { + var emphasizeAPI: EmphasizeAPI? { get } + var text: String { get } + + var cursorPositions: [CursorPosition] { get } + func setCursorPositions(_ positions: [CursorPosition]) + func updateCursorPosition() +} diff --git a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift new file mode 100644 index 000000000..c7cb79c3e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift @@ -0,0 +1,220 @@ +// +// SearchViewController.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import AppKit + +/// Creates a container controller for displaying and hiding a search bar with a content view. +final class SearchViewController: NSViewController { + weak var target: SearchTarget? + var childView: NSView + var searchBar: SearchBar! + + private var searchBarVerticalConstraint: NSLayoutConstraint! + + private(set) public var isShowingSearchBar: Bool = false + + init(target: SearchTarget, childView: NSView) { + self.target = target + self.childView = childView + super.init(nibName: nil, bundle: nil) + self.searchBar = SearchBar(delegate: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + super.loadView() + + // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to + // the search bar's bottom + // The search bar is constrained to the top of the view. + // The search bar's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. + // When visible, it's set to 0. + + view.addSubview(searchBar) + view.addSubview(childView) + + searchBarVerticalConstraint = searchBar.topAnchor.constraint( + equalTo: view.topAnchor, + constant: isShowingSearchBar ? 0 : searchBar.frame.height + ) + + NSLayoutConstraint.activate([ + // Constrain search bar + searchBarVerticalConstraint, + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + // Constrain child view + childView.topAnchor.constraint(equalTo: searchBar.bottomAnchor), + childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } +} + +// MARK: - Toggle Search Bar + +extension SearchViewController { + /// Toggle the search bar + func toggleSearchBar() { + if isShowingSearchBar { + hideSearchBar() + } else { + showSearchBar() + } + } + + /// Show the search bar + func showSearchBar() { + isShowingSearchBar = true + _ = searchBar?.searchField.becomeFirstResponder() + withAnimation { + // Update the search bar's top to be equal to the view's top. + searchBarVerticalConstraint.constant = 0 + searchBarVerticalConstraint.isActive = true + } + } + + /// Hide the search bar + func hideSearchBar() { + isShowingSearchBar = false + _ = searchBar?.searchField.resignFirstResponder() + withAnimation { + // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. + searchBarVerticalConstraint.constant = -searchBar.frame.height + searchBarVerticalConstraint.isActive = true + } + } + + /// Runs the `animatable` callback in an animation context with implicit animation enabled. + /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this + /// callback to have them animated. + private func withAnimation(_ animatable: () -> Void) { + NSAnimationContext.runAnimationGroup { animator in + animator.duration = 0.2 + animator.allowsImplicitAnimation = true + + animatable() + + view.updateConstraints() + view.layoutSubtreeIfNeeded() + } + } +} + +// MARK: - Search Bar Delegate + +extension SearchViewController: SearchBarDelegate { + func searchBarOnSubmit() { + target?.emphasizeAPI?.highlightNext() + // if let highlightedRange = target?.emphasizeAPI?.emphasizedRanges[target.emphasizeAPI?.emphasizedRangeIndex ?? 0] { + // target?.setCursorPositions([CursorPosition(range: highlightedRange.range)]) + // target?.updateCursorPosition() + // } + } + + func searchBarOnCancel() { + if isShowingSearchBar { + hideSearchBar() + } + } + + func searchBarDidUpdate(_ searchText: String) { + searchFile(query: searchText) + } + + func searchBarPrevButtonClicked() { + target?.emphasizeAPI?.highlightPrevious() + // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + // textView.scrollToRange(currentRange) + // } + } + + func searchBarNextButtonClicked() { + target?.emphasizeAPI?.highlightNext() + // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + // textView.scrollToRange(currentRange) + // self.gutterView.needsDisplay = true + // } + } + + func searchFile(query: String) { + let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: query) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions), + let text = target?.text else { + target?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + guard !matches.isEmpty else { + target?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let searchResults = matches.map { $0.range } + let bestHighlightIndex = getNearestHighlightIndex(matchRanges: searchResults) ?? 0 + print(searchResults.count) + target?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: 0) + target?.setCursorPositions([CursorPosition(range: searchResults[bestHighlightIndex])]) + } + + private func getNearestHighlightIndex(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-serach the part of the file that changed upwards + private func reSearch() { } + + // Returns true if string contains uppercase letter + // used for: ignores letter case if the search query is all lowercase + private func smartCase(str: String) -> Bool { + return str.range(of: "[A-Z]", options: .regularExpression) != nil + } +} diff --git a/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift b/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift new file mode 100644 index 000000000..e6e9b731a --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift @@ -0,0 +1,14 @@ +// +// File.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/11/25. +// + +import CodeEditTextView + +extension TextViewController: SearchTarget { + var emphasizeAPI: EmphasizeAPI? { + textView?.emphasizeAPI + } +} diff --git a/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift b/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift deleted file mode 100644 index 90aae4f2d..000000000 --- a/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// SearchManager.swift -// CodeEditSourceEditor -// -// Created by Tommy Ludwig on 03.02.25. -// - -import AppKit -import CodeEditTextView - -extension TextViewController { - @objc func searchFieldUpdated(_ notification: Notification) { - if let textField = notification.object as? NSTextField { - searchFile(query: textField.stringValue) - } - } - - @objc func onSubmit() { - if let highlightedRange = textView.emphasizeAPI?.emphasizedRanges[textView.emphasizeAPI?.emphasizedRangeIndex ?? 0] { - setCursorPositions([CursorPosition(range: highlightedRange.range)]) - updateCursorPosition() - } - } - - @objc func prevButtonClicked() { - textView?.emphasizeAPI?.highlightPrevious() - if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - textView.scrollToRange(currentRange) - } - } - - @objc func nextButtonClicked() { - textView?.emphasizeAPI?.highlightNext() - if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - textView.scrollToRange(currentRange) - self.gutterView.needsDisplay = true - } - } - - func searchFile(query: String) { - let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: query) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions) else { - textView?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - guard !matches.isEmpty else { - textView?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let searchResults = matches.map { $0.range } - let bestHighlightIndex = getNearestHighlightIndex(ranges: searchResults) ?? 0 - print(searchResults.count) - textView?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: bestHighlightIndex) - cursorPositions = [CursorPosition(range: searchResults[bestHighlightIndex])] - } - - private func getNearestHighlightIndex(ranges: [NSRange]) -> Int? { - // order the array as follows - // Found: 1 -> 2 -> 3 -> 4 - // Cursor: | - // Result: 3 -> 4 -> 1 -> 2 - guard let cursorPosition = cursorPositions.first else { return nil } - let start = cursorPosition.range.location - - var left = 0 - var right = ranges.count - 1 - var bestIndex = -1 - var bestDiff = Int.max // Stores the closest difference - - while left <= right { - let mid = left + (right - left) / 2 - let midStart = ranges[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-serach the part of the file that changed upwards - private func reSearch() { } - - // Returns true if string contains uppercase letter - // used for: ignores letter case if the search query is all lowercase - private func smartCase(str: String) -> Bool { - return str.range(of: "[A-Z]", options: .regularExpression) != nil - } -} From 5800d05762f70798d78e58c8b1d2a75df381e245 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 11 Mar 2025 11:16:16 -0500 Subject: [PATCH 03/37] Set Initial Search Bar Constraints Correctly --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- .../Search/SearchViewController.swift | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aa1aeeba9..3bf3311bb 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" } }, { diff --git a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift index c7cb79c3e..c3bb3f540 100644 --- a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift +++ b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift @@ -40,10 +40,7 @@ final class SearchViewController: NSViewController { view.addSubview(searchBar) view.addSubview(childView) - searchBarVerticalConstraint = searchBar.topAnchor.constraint( - equalTo: view.topAnchor, - constant: isShowingSearchBar ? 0 : searchBar.frame.height - ) + searchBarVerticalConstraint = searchBar.topAnchor.constraint(equalTo: view.topAnchor) NSLayoutConstraint.activate([ // Constrain search bar @@ -58,6 +55,15 @@ final class SearchViewController: NSViewController { childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } + + override func viewWillAppear() { + super.viewWillAppear() + if isShowingSearchBar { // Update constraints for initial state + showSearchBar() + } else { + hideSearchBar() + } + } } // MARK: - Toggle Search Bar @@ -89,7 +95,7 @@ extension SearchViewController { _ = searchBar?.searchField.resignFirstResponder() withAnimation { // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. - searchBarVerticalConstraint.constant = -searchBar.frame.height + searchBarVerticalConstraint.constant = -searchBar.fittingSize.height searchBarVerticalConstraint.isActive = true } } From 1488a86cfc3684cb048f5916da646bc63a83012b Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Mon, 10 Mar 2025 19:50:28 +0100 Subject: [PATCH 04/37] Add search manager and search field --- .../project.pbxproj | 14 +++ .../TextViewController+LoadView.swift | 61 +++++++++- .../Controller/TextViewController.swift | 11 +- .../SearchManager/SearchManager.swift | 110 ++++++++++++++++++ 4 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 2ce04dadc..328c585b6 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61621C602C74FB2200494A4A /* CodeEditSourceEditor */; }; + 61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */; }; + 61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */; }; 6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652D2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift */; }; 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13652F2B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift */; }; 6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365312B8A7B94004A1D18 /* ContentView.swift */; }; @@ -41,6 +43,8 @@ buildActionMask = 2147483647; files = ( 61621C612C74FB2200494A4A /* CodeEditSourceEditor in Frameworks */, + 61CE772F2D19BF7D00908C57 /* CodeEditSourceEditor in Frameworks */, + 61CE77322D19BFAA00908C57 /* CodeEditSourceEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -140,6 +144,8 @@ name = CodeEditSourceEditorExample; packageProductDependencies = ( 61621C602C74FB2200494A4A /* CodeEditSourceEditor */, + 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */, + 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */, ); productName = CodeEditSourceEditorExample; productReference = 6C13652A2B8A7B94004A1D18 /* CodeEditSourceEditorExample.app */; @@ -412,6 +418,14 @@ isa = XCSwiftPackageProductDependency; productName = CodeEditSourceEditor; }; + 61CE772E2D19BF7D00908C57 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; + 61CE77312D19BFAA00908C57 /* CodeEditSourceEditor */ = { + isa = XCSwiftPackageProductDependency; + productName = CodeEditSourceEditor; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6C1365222B8A7B94004A1D18 /* Project object */; diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 34eb0dd42..3b9ecb963 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -11,10 +11,15 @@ import AppKit extension TextViewController { // swiftlint:disable:next function_body_length override public func loadView() { + let stackView = NSStackView() + stackView.orientation = .vertical + stackView.spacing = 10 + stackView.alignment = .leading + stackView.translatesAutoresizingMaskIntoConstraints = false + scrollView = NSScrollView() textView.postsFrameChangedNotifications = true textView.translatesAutoresizingMaskIntoConstraints = false - scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.contentView.postsFrameChangedNotifications = true scrollView.hasVerticalScroller = true @@ -34,7 +39,46 @@ extension TextViewController { for: .horizontal ) - self.view = scrollView + searchField = NSTextField() + searchField.placeholderString = "Search..." + searchField.controlSize = .regular // TODO: a + searchField.focusRingType = .none + searchField.bezelStyle = .roundedBezel + searchField.drawsBackground = true + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.action = #selector(onSubmit) + searchField.target = self + + prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) + prevButton.bezelStyle = .texturedRounded + prevButton.controlSize = .small + prevButton.translatesAutoresizingMaskIntoConstraints = false + + nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) + nextButton.bezelStyle = .texturedRounded + nextButton.controlSize = .small + nextButton.translatesAutoresizingMaskIntoConstraints = false + + stackview = NSStackView() + stackview.orientation = .horizontal + stackview.spacing = 8 + stackview.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) + stackview.translatesAutoresizingMaskIntoConstraints = false + + stackview.addView(searchField, in: .leading) + stackview.addView(prevButton, in: .trailing) + stackview.addView(nextButton, in: .trailing) + + NotificationCenter.default.addObserver( + self, + selector: #selector(searchFieldUpdated(_:)), + name: NSControl.textDidChangeNotification, + object: searchField + ) + + stackView.addArrangedSubview(stackview) + stackView.addArrangedSubview(scrollView) + self.view = stackView if let _undoManager { textView.setUndoManager(_undoManager) } @@ -46,10 +90,15 @@ extension TextViewController { setUpTextFormation() NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + + // scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), +// scrollView.topAnchor.constraint(equalTo: view.topAnchor), +// scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) if !cursorPositions.isEmpty { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index a8e275e49..9550bb046 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -21,9 +21,16 @@ public class TextViewController: NSViewController { public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") var scrollView: NSScrollView! - private(set) public var textView: TextView! + + // SEARCH + var stackview: NSStackView! + var searchField: NSTextField! + var prevButton: NSButton! + var nextButton: NSButton! + + var textView: TextView! var gutterView: GutterView! - internal var _undoManager: CEUndoManager? + internal var _undoManager: CEUndoManager! /// Internal reference to any injected layers in the text view. internal var highlightLayers: [CALayer] = [] internal var systemAppearance: NSAppearance.Name? diff --git a/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift b/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift new file mode 100644 index 000000000..90aae4f2d --- /dev/null +++ b/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift @@ -0,0 +1,110 @@ +// +// SearchManager.swift +// CodeEditSourceEditor +// +// Created by Tommy Ludwig on 03.02.25. +// + +import AppKit +import CodeEditTextView + +extension TextViewController { + @objc func searchFieldUpdated(_ notification: Notification) { + if let textField = notification.object as? NSTextField { + searchFile(query: textField.stringValue) + } + } + + @objc func onSubmit() { + if let highlightedRange = textView.emphasizeAPI?.emphasizedRanges[textView.emphasizeAPI?.emphasizedRangeIndex ?? 0] { + setCursorPositions([CursorPosition(range: highlightedRange.range)]) + updateCursorPosition() + } + } + + @objc func prevButtonClicked() { + textView?.emphasizeAPI?.highlightPrevious() + if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + textView.scrollToRange(currentRange) + } + } + + @objc func nextButtonClicked() { + textView?.emphasizeAPI?.highlightNext() + if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + textView.scrollToRange(currentRange) + self.gutterView.needsDisplay = true + } + } + + func searchFile(query: String) { + let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: query) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions) else { + textView?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + guard !matches.isEmpty else { + textView?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let searchResults = matches.map { $0.range } + let bestHighlightIndex = getNearestHighlightIndex(ranges: searchResults) ?? 0 + print(searchResults.count) + textView?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: bestHighlightIndex) + cursorPositions = [CursorPosition(range: searchResults[bestHighlightIndex])] + } + + private func getNearestHighlightIndex(ranges: [NSRange]) -> Int? { + // order the array as follows + // Found: 1 -> 2 -> 3 -> 4 + // Cursor: | + // Result: 3 -> 4 -> 1 -> 2 + guard let cursorPosition = cursorPositions.first else { return nil } + let start = cursorPosition.range.location + + var left = 0 + var right = ranges.count - 1 + var bestIndex = -1 + var bestDiff = Int.max // Stores the closest difference + + while left <= right { + let mid = left + (right - left) / 2 + let midStart = ranges[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-serach the part of the file that changed upwards + private func reSearch() { } + + // Returns true if string contains uppercase letter + // used for: ignores letter case if the search query is all lowercase + private func smartCase(str: String) -> Bool { + return str.range(of: "[A-Z]", options: .regularExpression) != nil + } +} From dc5e6d8c940cf253a884e48cf1bee5722e11b2f7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 11 Mar 2025 11:07:14 -0500 Subject: [PATCH 05/37] Add Search Container, Search Bar, Show/Hide Commands, Animations --- .../xcshareddata/swiftpm/Package.resolved | 9 + Package.resolved | 13 +- .../TextViewController+LoadView.swift | 125 +++++----- .../Controller/TextViewController.swift | 2 + .../Search/SearchBar.swift | 95 ++++++++ .../Search/SearchTarget.swift | 19 ++ .../Search/SearchViewController.swift | 220 ++++++++++++++++++ .../TextViewController+SearchTarget.swift | 14 ++ .../SearchManager/SearchManager.swift | 110 --------- 9 files changed, 425 insertions(+), 182 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Search/SearchBar.swift create mode 100644 Sources/CodeEditSourceEditor/Search/SearchTarget.swift create mode 100644 Sources/CodeEditSourceEditor/Search/SearchViewController.swift create mode 100644 Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift delete mode 100644 Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a7d1aaa89..afaca9f90 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" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "1792167c751b6668b4743600d2cf73d2829dd18a", + "version" : "0.7.9" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index a85702ace..a6c5b01b7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/lukepistrol/SwiftLintPlugin", "state" : { - "revision" : "bea71d23db993c58934ee704f798a66d7b8cb626", - "version" : "0.57.0" + "revision" : "3825ebf8d55bb877c91bc897e8e3d0c001f16fba", + "version" : "0.58.2" } }, { @@ -71,15 +71,6 @@ "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", "version" : "0.9.0" } - }, - { - "identity" : "tree-sitter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tree-sitter/tree-sitter", - "state" : { - "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", - "version" : "0.23.2" - } } ], "version" : 2 diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 3b9ecb963..e80c416ad 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -11,11 +11,7 @@ import AppKit extension TextViewController { // swiftlint:disable:next function_body_length override public func loadView() { - let stackView = NSStackView() - stackView.orientation = .vertical - stackView.spacing = 10 - stackView.alignment = .leading - stackView.translatesAutoresizingMaskIntoConstraints = false + super.loadView() scrollView = NSScrollView() textView.postsFrameChangedNotifications = true @@ -39,46 +35,12 @@ extension TextViewController { for: .horizontal ) - searchField = NSTextField() - searchField.placeholderString = "Search..." - searchField.controlSize = .regular // TODO: a - searchField.focusRingType = .none - searchField.bezelStyle = .roundedBezel - searchField.drawsBackground = true - searchField.translatesAutoresizingMaskIntoConstraints = false - searchField.action = #selector(onSubmit) - searchField.target = self - - prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) - prevButton.bezelStyle = .texturedRounded - prevButton.controlSize = .small - prevButton.translatesAutoresizingMaskIntoConstraints = false - - nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) - nextButton.bezelStyle = .texturedRounded - nextButton.controlSize = .small - nextButton.translatesAutoresizingMaskIntoConstraints = false - - stackview = NSStackView() - stackview.orientation = .horizontal - stackview.spacing = 8 - stackview.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) - stackview.translatesAutoresizingMaskIntoConstraints = false - - stackview.addView(searchField, in: .leading) - stackview.addView(prevButton, in: .trailing) - stackview.addView(nextButton, in: .trailing) + let searchController = SearchViewController(target: self, childView: scrollView) + addChild(searchController) + self.view.addSubview(searchController.view) + searchController.view.viewDidMoveToSuperview() + self.searchController = searchController - NotificationCenter.default.addObserver( - self, - selector: #selector(searchFieldUpdated(_:)), - name: NSControl.textDidChangeNotification, - object: searchField - ) - - stackView.addArrangedSubview(stackview) - stackView.addArrangedSubview(scrollView) - self.view = stackView if let _undoManager { textView.setUndoManager(_undoManager) } @@ -90,15 +52,10 @@ extension TextViewController { setUpTextFormation() NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - stackView.topAnchor.constraint(equalTo: view.topAnchor), - stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - - // scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// scrollView.topAnchor.constraint(equalTo: view.topAnchor), -// scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + searchController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + searchController.view.topAnchor.constraint(equalTo: view.topAnchor), + searchController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) if !cursorPositions.isEmpty { @@ -162,18 +119,64 @@ extension TextViewController { if let localEventMonitor = self.localEvenMonitor { NSEvent.removeMonitor(localEventMonitor) } - self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in guard self?.view.window?.firstResponder == self?.textView else { return event } + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + + switch (modifierFlags, event.charactersIgnoringModifiers?.lowercased()) { + case (.command, "/"): + self?.handleCommandSlash() + return nil + case (.command, "f"): + _ = self?.textView.resignFirstResponder() + self?.searchController?.showSearchBar() + return nil + case ([], "\u{1b}"): // Escape key + self?.searchController?.hideSearchBar() + _ = self?.textView.becomeFirstResponder() + self?.textView.selectionManager.setSelectedRanges( + self?.textView.selectionManager.textSelections.map { $0.range } ?? [] + ) + return nil + default: + return event + } + } + } + func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { + let commandKey = NSEvent.ModifierFlags.command.rawValue + + switch (modifierFlags, event.charactersIgnoringModifiers) { + case (commandKey, "/"): + handleCommandSlash() + return nil + case (commandKey, "["): + handleIndent(inwards: true) + return nil + case (commandKey, "]"): + handleIndent() + return nil + case (_, _): + return event + } + } - let tabKey: UInt16 = 0x30 - let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue + /// Handles the tab key event. + /// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines + /// are highlighted and handles indenting accordingly. + /// + /// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method. + func handleTab(event: NSEvent, modifierFalgs: UInt) -> NSEvent? { + let shiftKey = NSEvent.ModifierFlags.shift.rawValue - if event.keyCode == tabKey { - return self?.handleTab(event: event, modifierFalgs: modifierFlags) - } else { - return self?.handleCommand(event: event, modifierFlags: modifierFlags) - } + if modifierFalgs == shiftKey { + handleIndent(inwards: true) + } else { + // Only allow tab to work if multiple lines are selected + guard multipleLinesHighlighted() else { return event } + handleIndent() } + return nil } func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 9550bb046..8afc3ec3a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -20,6 +20,8 @@ public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") + weak var searchController: SearchViewController? + var scrollView: NSScrollView! // SEARCH diff --git a/Sources/CodeEditSourceEditor/Search/SearchBar.swift b/Sources/CodeEditSourceEditor/Search/SearchBar.swift new file mode 100644 index 000000000..16e15791e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/SearchBar.swift @@ -0,0 +1,95 @@ +// +// SearchBar.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import AppKit + +protocol SearchBarDelegate: AnyObject { + func searchBarOnSubmit() + func searchBarOnCancel() + func searchBarDidUpdate(_ searchText: String) + func searchBarPrevButtonClicked() + func searchBarNextButtonClicked() +} + +/// A control for searching a document and navigating results. +final class SearchBar: NSStackView { + weak var searchDelegate: SearchBarDelegate? + + var searchField: NSTextField! + var prevButton: NSButton! + var nextButton: NSButton! + + init(delegate: SearchBarDelegate?) { + super.init(frame: .zero) + + self.searchDelegate = delegate + + searchField = NSTextField() + searchField.placeholderString = "Search..." + searchField.controlSize = .regular // TODO: a + searchField.focusRingType = .none + searchField.bezelStyle = .roundedBezel + searchField.drawsBackground = true + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.action = #selector(onSubmit) + searchField.target = self + + prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) + prevButton.bezelStyle = .texturedRounded + prevButton.controlSize = .small + prevButton.translatesAutoresizingMaskIntoConstraints = false + + nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) + nextButton.bezelStyle = .texturedRounded + nextButton.controlSize = .small + nextButton.translatesAutoresizingMaskIntoConstraints = false + + self.orientation = .horizontal + self.spacing = 8 + self.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) + self.translatesAutoresizingMaskIntoConstraints = false + + self.addView(searchField, in: .leading) + self.addView(prevButton, in: .trailing) + self.addView(nextButton, in: .trailing) + + NotificationCenter.default.addObserver( + self, + selector: #selector(searchFieldUpdated(_:)), + name: NSControl.textDidChangeNotification, + object: searchField + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Hide the search bar when escape is pressed + override func cancelOperation(_ sender: Any?) { + searchDelegate?.searchBarOnCancel() + } + + // MARK: - Delegate Messaging + + @objc func searchFieldUpdated(_ notification: Notification) { + guard let searchField = notification.object as? NSTextField else { return } + searchDelegate?.searchBarDidUpdate(searchField.stringValue) + } + + @objc func onSubmit() { + searchDelegate?.searchBarOnSubmit() + } + + @objc func prevButtonClicked() { + searchDelegate?.searchBarPrevButtonClicked() + } + + @objc func nextButtonClicked() { + searchDelegate?.searchBarNextButtonClicked() + } +} diff --git a/Sources/CodeEditSourceEditor/Search/SearchTarget.swift b/Sources/CodeEditSourceEditor/Search/SearchTarget.swift new file mode 100644 index 000000000..e00682603 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/SearchTarget.swift @@ -0,0 +1,19 @@ +// +// SearchTarget.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +// This dependency is not ideal, maybe we could make this another protocol that the emphasize API conforms to similar +// to this one? +import CodeEditTextView + +protocol SearchTarget: AnyObject { + var emphasizeAPI: EmphasizeAPI? { get } + var text: String { get } + + var cursorPositions: [CursorPosition] { get } + func setCursorPositions(_ positions: [CursorPosition]) + func updateCursorPosition() +} diff --git a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift new file mode 100644 index 000000000..c7cb79c3e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift @@ -0,0 +1,220 @@ +// +// SearchViewController.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import AppKit + +/// Creates a container controller for displaying and hiding a search bar with a content view. +final class SearchViewController: NSViewController { + weak var target: SearchTarget? + var childView: NSView + var searchBar: SearchBar! + + private var searchBarVerticalConstraint: NSLayoutConstraint! + + private(set) public var isShowingSearchBar: Bool = false + + init(target: SearchTarget, childView: NSView) { + self.target = target + self.childView = childView + super.init(nibName: nil, bundle: nil) + self.searchBar = SearchBar(delegate: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + super.loadView() + + // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to + // the search bar's bottom + // The search bar is constrained to the top of the view. + // The search bar's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. + // When visible, it's set to 0. + + view.addSubview(searchBar) + view.addSubview(childView) + + searchBarVerticalConstraint = searchBar.topAnchor.constraint( + equalTo: view.topAnchor, + constant: isShowingSearchBar ? 0 : searchBar.frame.height + ) + + NSLayoutConstraint.activate([ + // Constrain search bar + searchBarVerticalConstraint, + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + // Constrain child view + childView.topAnchor.constraint(equalTo: searchBar.bottomAnchor), + childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } +} + +// MARK: - Toggle Search Bar + +extension SearchViewController { + /// Toggle the search bar + func toggleSearchBar() { + if isShowingSearchBar { + hideSearchBar() + } else { + showSearchBar() + } + } + + /// Show the search bar + func showSearchBar() { + isShowingSearchBar = true + _ = searchBar?.searchField.becomeFirstResponder() + withAnimation { + // Update the search bar's top to be equal to the view's top. + searchBarVerticalConstraint.constant = 0 + searchBarVerticalConstraint.isActive = true + } + } + + /// Hide the search bar + func hideSearchBar() { + isShowingSearchBar = false + _ = searchBar?.searchField.resignFirstResponder() + withAnimation { + // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. + searchBarVerticalConstraint.constant = -searchBar.frame.height + searchBarVerticalConstraint.isActive = true + } + } + + /// Runs the `animatable` callback in an animation context with implicit animation enabled. + /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this + /// callback to have them animated. + private func withAnimation(_ animatable: () -> Void) { + NSAnimationContext.runAnimationGroup { animator in + animator.duration = 0.2 + animator.allowsImplicitAnimation = true + + animatable() + + view.updateConstraints() + view.layoutSubtreeIfNeeded() + } + } +} + +// MARK: - Search Bar Delegate + +extension SearchViewController: SearchBarDelegate { + func searchBarOnSubmit() { + target?.emphasizeAPI?.highlightNext() + // if let highlightedRange = target?.emphasizeAPI?.emphasizedRanges[target.emphasizeAPI?.emphasizedRangeIndex ?? 0] { + // target?.setCursorPositions([CursorPosition(range: highlightedRange.range)]) + // target?.updateCursorPosition() + // } + } + + func searchBarOnCancel() { + if isShowingSearchBar { + hideSearchBar() + } + } + + func searchBarDidUpdate(_ searchText: String) { + searchFile(query: searchText) + } + + func searchBarPrevButtonClicked() { + target?.emphasizeAPI?.highlightPrevious() + // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + // textView.scrollToRange(currentRange) + // } + } + + func searchBarNextButtonClicked() { + target?.emphasizeAPI?.highlightNext() + // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { + // textView.scrollToRange(currentRange) + // self.gutterView.needsDisplay = true + // } + } + + func searchFile(query: String) { + let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: query) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions), + let text = target?.text else { + target?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + guard !matches.isEmpty else { + target?.emphasizeAPI?.removeEmphasizeLayers() + return + } + + let searchResults = matches.map { $0.range } + let bestHighlightIndex = getNearestHighlightIndex(matchRanges: searchResults) ?? 0 + print(searchResults.count) + target?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: 0) + target?.setCursorPositions([CursorPosition(range: searchResults[bestHighlightIndex])]) + } + + private func getNearestHighlightIndex(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-serach the part of the file that changed upwards + private func reSearch() { } + + // Returns true if string contains uppercase letter + // used for: ignores letter case if the search query is all lowercase + private func smartCase(str: String) -> Bool { + return str.range(of: "[A-Z]", options: .regularExpression) != nil + } +} diff --git a/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift b/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift new file mode 100644 index 000000000..e6e9b731a --- /dev/null +++ b/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift @@ -0,0 +1,14 @@ +// +// File.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/11/25. +// + +import CodeEditTextView + +extension TextViewController: SearchTarget { + var emphasizeAPI: EmphasizeAPI? { + textView?.emphasizeAPI + } +} diff --git a/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift b/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift deleted file mode 100644 index 90aae4f2d..000000000 --- a/Sources/CodeEditSourceEditor/SearchManager/SearchManager.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// SearchManager.swift -// CodeEditSourceEditor -// -// Created by Tommy Ludwig on 03.02.25. -// - -import AppKit -import CodeEditTextView - -extension TextViewController { - @objc func searchFieldUpdated(_ notification: Notification) { - if let textField = notification.object as? NSTextField { - searchFile(query: textField.stringValue) - } - } - - @objc func onSubmit() { - if let highlightedRange = textView.emphasizeAPI?.emphasizedRanges[textView.emphasizeAPI?.emphasizedRangeIndex ?? 0] { - setCursorPositions([CursorPosition(range: highlightedRange.range)]) - updateCursorPosition() - } - } - - @objc func prevButtonClicked() { - textView?.emphasizeAPI?.highlightPrevious() - if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - textView.scrollToRange(currentRange) - } - } - - @objc func nextButtonClicked() { - textView?.emphasizeAPI?.highlightNext() - if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - textView.scrollToRange(currentRange) - self.gutterView.needsDisplay = true - } - } - - func searchFile(query: String) { - let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: query) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions) else { - textView?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - guard !matches.isEmpty else { - textView?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let searchResults = matches.map { $0.range } - let bestHighlightIndex = getNearestHighlightIndex(ranges: searchResults) ?? 0 - print(searchResults.count) - textView?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: bestHighlightIndex) - cursorPositions = [CursorPosition(range: searchResults[bestHighlightIndex])] - } - - private func getNearestHighlightIndex(ranges: [NSRange]) -> Int? { - // order the array as follows - // Found: 1 -> 2 -> 3 -> 4 - // Cursor: | - // Result: 3 -> 4 -> 1 -> 2 - guard let cursorPosition = cursorPositions.first else { return nil } - let start = cursorPosition.range.location - - var left = 0 - var right = ranges.count - 1 - var bestIndex = -1 - var bestDiff = Int.max // Stores the closest difference - - while left <= right { - let mid = left + (right - left) / 2 - let midStart = ranges[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-serach the part of the file that changed upwards - private func reSearch() { } - - // Returns true if string contains uppercase letter - // used for: ignores letter case if the search query is all lowercase - private func smartCase(str: String) -> Bool { - return str.range(of: "[A-Z]", options: .regularExpression) != nil - } -} From 5842295a7c12915547a27db5b834a16a8df399f8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 11 Mar 2025 11:16:16 -0500 Subject: [PATCH 06/37] Set Initial Search Bar Constraints Correctly --- .../Search/SearchViewController.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift index c7cb79c3e..c3bb3f540 100644 --- a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift +++ b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift @@ -40,10 +40,7 @@ final class SearchViewController: NSViewController { view.addSubview(searchBar) view.addSubview(childView) - searchBarVerticalConstraint = searchBar.topAnchor.constraint( - equalTo: view.topAnchor, - constant: isShowingSearchBar ? 0 : searchBar.frame.height - ) + searchBarVerticalConstraint = searchBar.topAnchor.constraint(equalTo: view.topAnchor) NSLayoutConstraint.activate([ // Constrain search bar @@ -58,6 +55,15 @@ final class SearchViewController: NSViewController { childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } + + override func viewWillAppear() { + super.viewWillAppear() + if isShowingSearchBar { // Update constraints for initial state + showSearchBar() + } else { + hideSearchBar() + } + } } // MARK: - Toggle Search Bar @@ -89,7 +95,7 @@ extension SearchViewController { _ = searchBar?.searchField.resignFirstResponder() withAnimation { // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. - searchBarVerticalConstraint.constant = -searchBar.frame.height + searchBarVerticalConstraint.constant = -searchBar.fittingSize.height searchBarVerticalConstraint.isActive = true } } From fd16af7656f0634487e368ef07d763ff02fc2e45 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 12 Mar 2025 16:11:54 -0500 Subject: [PATCH 07/37] Renamed SearchBar to FindPanel. Styled FindPanel. Added number of matches and clear button. When text field loses focus, emphasis layers are removed. Styled example app. --- .../CodeEditSourceEditorExampleApp.swift | 1 + .../Views/ContentView.swift | 60 ++-- .../CodeEditUI/IconButtonStyle.swift | 120 ++++++++ .../CodeEditUI/IconToggleStyle.swift | 59 ++++ .../CodeEditUI/PanelStyles.swift | 71 +++++ .../CodeEditUI/PanelTextField.swift | 139 +++++++++ .../TextViewController+LoadView.swift | 10 +- .../Controller/TextViewController.swift | 2 +- .../TextViewController+SearchTarget.swift | 4 +- .../CodeEditSourceEditor/Find/FindPanel.swift | 86 ++++++ .../Find/FindPanelDelegate.swift | 18 ++ .../Find/FindPanelView.swift | 86 ++++++ .../Find/FindPanelViewModel.swift | 60 ++++ .../FindTarget.swift} | 6 +- .../Find/FindViewController.swift | 288 ++++++++++++++++++ .../Search/SearchBar.swift | 95 ------ .../Search/SearchViewController.swift | 226 -------------- 17 files changed, 973 insertions(+), 358 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift create mode 100644 Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift create mode 100644 Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift create mode 100644 Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift rename Sources/CodeEditSourceEditor/{Search => Extensions}/TextViewController+SearchTarget.swift (70%) create mode 100644 Sources/CodeEditSourceEditor/Find/FindPanel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelView.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelViewModel.swift rename Sources/CodeEditSourceEditor/{Search/SearchTarget.swift => Find/FindTarget.swift} (87%) create mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController.swift delete mode 100644 Sources/CodeEditSourceEditor/Search/SearchBar.swift delete mode 100644 Sources/CodeEditSourceEditor/Search/SearchViewController.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift index ac078e338..4e8ed5e44 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift @@ -12,6 +12,7 @@ struct CodeEditSourceEditorExampleApp: App { var body: some Scene { DocumentGroup(newDocument: CodeEditSourceEditorExampleDocument()) { file in ContentView(document: file.$document, fileURL: file.fileURL) + .preferredColorScheme(.light) } } } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index ea55c35b5..9a7fb2ae5 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -28,26 +28,7 @@ struct ContentView: View { } var body: some View { - VStack(spacing: 0) { - HStack { - Text("Language") - LanguagePicker(language: $language) - .frame(maxWidth: 100) - Toggle("Wrap Lines", isOn: $wrapLines) - if #available(macOS 14, *) { - Toggle("Use System Cursor", isOn: $useSystemCursor) - } else { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .disabled(true) - .help("macOS 14 required") - } - Spacer() - Text(getLabel(cursorPositions)) - } - .padding(4) - .zIndex(2) - .background(Color(NSColor.windowBackgroundColor)) - Divider() + VStack { ZStack { if isInLongParse { VStack { @@ -74,10 +55,41 @@ struct ContentView: View { cursorPositions: $cursorPositions, useSystemCursor: useSystemCursor ) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + Divider() + HStack { + Toggle("Wrap Lines", isOn: $wrapLines) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + if #available(macOS 14, *) { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + } else { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .disabled(true) + .help("macOS 14 required") + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + } + Spacer() + Text(getLabel(cursorPositions)) + Divider() + .frame(height: 12) + LanguagePicker(language: $language) + .buttonStyle(.borderless) + } + .padding(.horizontal, 8) + .frame(height: 28) + } + .background(.bar) + .zIndex(2) + } + } + .onAppear { + self.language = detectLanguage(fileURL: fileURL) ?? .default } - } - .onAppear { - self.language = detectLanguage(fileURL: fileURL) ?? .default } .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in withAnimation(.easeIn(duration: 0.1)) { @@ -105,7 +117,7 @@ struct ContentView: View { /// - Returns: A string describing the user's location in a document. func getLabel(_ cursorPositions: [CursorPosition]) -> String { if cursorPositions.isEmpty { - return "" + return "No cursor" } // More than one selection, display the number of selections. diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift b/Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift new file mode 100644 index 000000000..dfc6ff785 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift @@ -0,0 +1,120 @@ +// +// IconButtonStyle.swift +// CodeEdit +// +// Created by Austin Condiff on 11/9/23. +// + +import SwiftUI + +struct IconButtonStyle: ButtonStyle { + var isActive: Bool? + var font: Font? + var size: CGSize? + + init(isActive: Bool? = nil, font: Font? = nil, size: CGFloat? = nil) { + self.isActive = isActive + self.font = font + self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) + } + + init(isActive: Bool? = nil, font: Font? = nil, size: CGSize? = nil) { + self.isActive = isActive + self.font = font + self.size = size + } + + init(isActive: Bool? = nil, font: Font? = nil) { + self.isActive = isActive + self.font = font + self.size = nil + } + + func makeBody(configuration: ButtonStyle.Configuration) -> some View { + IconButton( + configuration: configuration, + isActive: isActive, + font: font, + size: size + ) + } + + struct IconButton: View { + let configuration: ButtonStyle.Configuration + var isActive: Bool + var font: Font + var size: CGSize? + @Environment(\.controlActiveState) + private var controlActiveState + @Environment(\.isEnabled) + private var isEnabled: Bool + @Environment(\.colorScheme) + private var colorScheme + + init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?, size: CGFloat?) { + self.configuration = configuration + self.isActive = isActive ?? false + self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) + self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) + } + + init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?, size: CGSize?) { + self.configuration = configuration + self.isActive = isActive ?? false + self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) + self.size = size ?? nil + } + + init(configuration: ButtonStyle.Configuration, isActive: Bool?, font: Font?) { + self.configuration = configuration + self.isActive = isActive ?? false + self.font = font ?? Font.system(size: 14.5, weight: .regular, design: .default) + self.size = nil + } + + var body: some View { + configuration.label + .font(font) + .foregroundColor( + isActive + ? Color(.controlAccentColor) + : Color(.secondaryLabelColor) + ) + .frame(width: size?.width, height: size?.height, alignment: .center) + .contentShape(Rectangle()) + .brightness( + configuration.isPressed + ? colorScheme == .dark + ? 0.5 + : isActive ? -0.25 : -0.75 + : 0 + ) + .opacity(controlActiveState == .inactive ? 0.5 : 1) + .symbolVariant(isActive ? .fill : .none) + } + } +} + +extension ButtonStyle where Self == IconButtonStyle { + static func icon( + isActive: Bool? = false, + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGFloat? = 24 + ) -> IconButtonStyle { + return IconButtonStyle(isActive: isActive, font: font, size: size) + } + static func icon( + isActive: Bool? = false, + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGSize? = CGSize(width: 24, height: 24) + ) -> IconButtonStyle { + return IconButtonStyle(isActive: isActive, font: font, size: size) + } + static func icon( + isActive: Bool? = false, + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) + ) -> IconButtonStyle { + return IconButtonStyle(isActive: isActive, font: font) + } + static var icon: IconButtonStyle { .init() } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift b/Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift new file mode 100644 index 000000000..2382dfc34 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift @@ -0,0 +1,59 @@ +// +// IconToggleStyle.swift +// CodeEdit +// +// Created by Austin Condiff on 11/9/23. +// + +import SwiftUI + +struct IconToggleStyle: ToggleStyle { + var font: Font? + var size: CGSize? + + @State var isPressing = false + + init(font: Font? = nil, size: CGFloat? = nil) { + self.font = font + self.size = size == nil ? nil : CGSize(width: size ?? 0, height: size ?? 0) + } + + init(font: Font? = nil, size: CGSize? = nil) { + self.font = font + self.size = size + } + + init(font: Font? = nil) { + self.font = font + self.size = nil + } + + func makeBody(configuration: ToggleStyle.Configuration) -> some View { + Button( + action: { configuration.isOn.toggle() }, + label: { configuration.label } + ) + .buttonStyle(.icon(isActive: configuration.isOn, font: font, size: size)) + } +} + +extension ToggleStyle where Self == IconToggleStyle { + static func icon( + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGFloat? = 24 + ) -> IconToggleStyle { + return IconToggleStyle(font: font, size: size) + } + static func icon( + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default), + size: CGSize? = CGSize(width: 24, height: 24) + ) -> IconToggleStyle { + return IconToggleStyle(font: font, size: size) + } + static func icon( + font: Font? = Font.system(size: 14.5, weight: .regular, design: .default) + ) -> IconToggleStyle { + return IconToggleStyle(font: font) + } + static var icon: IconToggleStyle { .init() } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift b/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift new file mode 100644 index 000000000..ce3e3108e --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift @@ -0,0 +1,71 @@ +// +// PanelStyles.swift +// CodeEdit +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI + +private struct InsideControlGroupKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var isInsideControlGroup: Bool { + get { self[InsideControlGroupKey.self] } + set { self[InsideControlGroupKey.self] = newValue } + } +} + +struct PanelControlGroupStyle: ControlGroupStyle { + @Environment(\.controlActiveState) private var controlActiveState + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 0) { + configuration.content + .buttonStyle(PanelButtonStyle()) + .environment(\.isInsideControlGroup, true) + .padding(.vertical, 1) + } + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color(nsColor: .tertiaryLabelColor), lineWidth: 1) + ) + .cornerRadius(4) + .clipped() + } +} + +struct PanelButtonStyle: ButtonStyle { + @Environment(\.colorScheme) var colorScheme + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.isEnabled) private var isEnabled + @Environment(\.isInsideControlGroup) private var isInsideControlGroup + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 12, weight: .regular)) + .foregroundColor(Color(.controlTextColor)) + .padding(.horizontal, 6) + .frame(height: isInsideControlGroup ? 16 : 18) + .background( + configuration.isPressed + ? colorScheme == .light + ? Color.black.opacity(0.06) + : Color.white.opacity(0.24) + : Color.clear + ) + .overlay( + Group { + if !isInsideControlGroup { + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color(nsColor: .tertiaryLabelColor), lineWidth: 1) + } + } + ) + .cornerRadius(isInsideControlGroup ? 0 : 4) + .clipped() + .contentShape(Rectangle()) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift b/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift new file mode 100644 index 000000000..beefdd7d4 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift @@ -0,0 +1,139 @@ +// +// PanelTextField.swift +// CodeEdit +// +// Created by Austin Condiff on 11/2/23. +// + +import SwiftUI +import Combine + +struct PanelTextField: View { + @Environment(\.colorScheme) + var colorScheme + + @Environment(\.controlActiveState) + private var controlActive + + @FocusState private var isFocused: Bool + + var label: String + + @Binding private var text: String + + let axis: Axis + + let leadingAccessories: LeadingAccessories? + + let trailingAccessories: TrailingAccessories? + + let helperText: String? + + var clearable: Bool + + var onClear: (() -> Void) + + var hasValue: Bool + + init( + _ label: String, + text: Binding, + axis: Axis? = .horizontal, + @ViewBuilder leadingAccessories: () -> LeadingAccessories? = { EmptyView() }, + @ViewBuilder trailingAccessories: () -> TrailingAccessories? = { EmptyView() }, + helperText: String? = nil, + clearable: Bool? = false, + onClear: (() -> Void)? = {}, + hasValue: Bool? = false + ) { + self.label = label + _text = text + self.axis = axis ?? .horizontal + self.leadingAccessories = leadingAccessories() + self.trailingAccessories = trailingAccessories() + 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 { + Color(.textBackgroundColor) + } else { + if colorScheme == .light { + Color.black.opacity(0.06) + } else { + Color.white.opacity(0.24) + } + } + } else { + if colorScheme == .light { + Color.clear + } else { + Color.white.opacity(0.14) + } + } + } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if let leading = leadingAccessories { + leading + .frame(height: 20) + } + HStack { + TextField(label, text: $text, axis: axis) + .textFieldStyle(.plain) + .focused($isFocused) + .controlSize(.small) + .padding(.horizontal, 8) + .padding(.vertical, 3.5) + .foregroundStyle(.primary) + if let helperText { + Text(helperText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if clearable == true { + Button { + self.text = "" + onClear() + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.icon(font: .system(size: 11, weight: .semibold), size: CGSize(width: 20, height: 20))) + .opacity(text.isEmpty ? 0 : 1) + .disabled(text.isEmpty) + } + if let trailing = trailingAccessories { + trailing + } + } + .fixedSize(horizontal: false, vertical: true) + .buttonStyle(.icon(font: .system(size: 11, weight: .semibold), size: CGSize(width: 28, height: 20))) + .toggleStyle(.icon(font: .system(size: 11, weight: .semibold), size: CGSize(width: 28, height: 20))) + .frame(minHeight: 22) + .background( + selectionBackground(isFocused) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .edgesIgnoringSafeArea(.all) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isFocused || !text.isEmpty || hasValue ? .tertiary : .quaternary, lineWidth: 1.25) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .disabled(true) + .edgesIgnoringSafeArea(.all) + ) + + .onTapGesture { + isFocused = true + } + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index e80c416ad..a4c8202e1 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -35,7 +35,7 @@ extension TextViewController { for: .horizontal ) - let searchController = SearchViewController(target: self, childView: scrollView) + let searchController = FindViewController(target: self, childView: scrollView) addChild(searchController) self.view.addSubview(searchController.view) searchController.view.viewDidMoveToSuperview() @@ -129,14 +129,10 @@ extension TextViewController { return nil case (.command, "f"): _ = self?.textView.resignFirstResponder() - self?.searchController?.showSearchBar() + self?.searchController?.showFindPanel() return nil case ([], "\u{1b}"): // Escape key - self?.searchController?.hideSearchBar() - _ = self?.textView.becomeFirstResponder() - self?.textView.selectionManager.setSelectedRanges( - self?.textView.selectionManager.textSelections.map { $0.range } ?? [] - ) + self?.searchController?.findPanel.cancel() return nil default: return event diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 8afc3ec3a..0751d0c1e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -20,7 +20,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") - weak var searchController: SearchViewController? + weak var searchController: FindViewController? var scrollView: NSScrollView! diff --git a/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift b/Sources/CodeEditSourceEditor/Extensions/TextViewController+SearchTarget.swift similarity index 70% rename from Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift rename to Sources/CodeEditSourceEditor/Extensions/TextViewController+SearchTarget.swift index e6e9b731a..abe90f30b 100644 --- a/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextViewController+SearchTarget.swift @@ -1,5 +1,5 @@ // -// File.swift +// TextViewController.swift // CodeEditSourceEditor // // Created by Khan Winter on 3/11/25. @@ -7,7 +7,7 @@ import CodeEditTextView -extension TextViewController: SearchTarget { +extension TextViewController: FindTarget { var emphasizeAPI: EmphasizeAPI? { textView?.emphasizeAPI } diff --git a/Sources/CodeEditSourceEditor/Find/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/FindPanel.swift new file mode 100644 index 000000000..1d8f8b9aa --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanel.swift @@ -0,0 +1,86 @@ +// +// 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 { + weak var searchDelegate: FindPanelDelegate? + private var hostingView: NSHostingView! + private var viewModel: FindPanelViewModel! + private weak var textView: NSView? + private var isViewReady = false + + init(delegate: FindPanelDelegate?, textView: NSView?) { + self.searchDelegate = delegate + self.textView = textView + super.init(frame: .zero) + + viewModel = FindPanelViewModel(delegate: searchDelegate) + 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.startObservingSearchText() + } + } + + 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: - Public Methods + + func cancel() { + viewModel.onCancel() + } + + func updateMatchCount(_ count: Int) { + viewModel.updateMatchCount(count) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift new file mode 100644 index 000000000..523d8fcd9 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -0,0 +1,18 @@ +// +// FindPanelDelegate.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import Foundation + +protocol FindPanelDelegate: AnyObject { + func findPanelOnSubmit() + func findPanelOnCancel() + func findPanelDidUpdate(_ searchText: String) + func findPanelPrevButtonClicked() + func findPanelNextButtonClicked() + func findPanelUpdateMatchCount(_ count: Int) + func findPanelClearEmphasis() +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/FindPanelView.swift new file mode 100644 index 000000000..d750224dd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelView.swift @@ -0,0 +1,86 @@ +// +// FindPanelView.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import AppKit + +struct FindPanelView: View { + @Environment(\.controlActiveState) var activeState + @ObservedObject var viewModel: FindPanelViewModel + @FocusState private var isFocused: Bool + + var body: some View { + HStack(spacing: 5) { + PanelTextField( + "Search...", + text: $viewModel.searchText, + leadingAccessories: { + Image(systemName: "magnifyingglass") + .padding(.leading, 8) + .foregroundStyle(activeState == .inactive ? .tertiary : .secondary) + .font(.system(size: 12)) + .frame(width: 16, height: 20) + }, + helperText: viewModel.searchText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .focused($isFocused) + .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) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + Button(action: viewModel.onCancel) { + Text("Done") + .padding(.horizontal, 5) + } + .buttonStyle(PanelButtonStyle()) + } + } + .padding(.horizontal, 5) + .frame(minHeight: 28) + .background(.bar) + .onAppear { + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { (event) -> NSEvent? in + if event.keyCode == 53 { // if esc pressed + viewModel.onCancel() + return nil // do not play "beep" sound + } + + return event + } + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/FindPanelViewModel.swift new file mode 100644 index 000000000..a51d9f906 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelViewModel.swift @@ -0,0 +1,60 @@ +// +// FindPanelViewModel.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import Combine + +class FindPanelViewModel: ObservableObject { + weak var delegate: FindPanelDelegate? + @Published var isFocused: Bool = false + @Published var searchText: String = "" + @Published var matchCount: Int = 0 + private var cancellables = Set() + + init(delegate: FindPanelDelegate?) { + self.delegate = delegate + } + + func startObservingSearchText() { + // Set up observer for searchText changes + $searchText + .sink { [weak self] newValue in + self?.delegate?.findPanelDidUpdate(newValue) + } + .store(in: &cancellables) + } + + func onSubmit() { + delegate?.findPanelOnSubmit() + } + + func onCancel() { + setFocus(false) // Remove focus from search field + delegate?.findPanelOnCancel() // Call delegate first + searchText = "" // Clear the search text last + } + + func prevButtonClicked() { + delegate?.findPanelPrevButtonClicked() + } + + func nextButtonClicked() { + delegate?.findPanelNextButtonClicked() + } + + func setFocus(_ focused: Bool) { + isFocused = focused + } + + func updateMatchCount(_ count: Int) { + matchCount = count + } + + func removeEmphasis() { + delegate?.findPanelClearEmphasis() + } +} diff --git a/Sources/CodeEditSourceEditor/Search/SearchTarget.swift b/Sources/CodeEditSourceEditor/Find/FindTarget.swift similarity index 87% rename from Sources/CodeEditSourceEditor/Search/SearchTarget.swift rename to Sources/CodeEditSourceEditor/Find/FindTarget.swift index e00682603..e6b0c7cf1 100644 --- a/Sources/CodeEditSourceEditor/Search/SearchTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindTarget.swift @@ -1,5 +1,5 @@ // -// SearchTarget.swift +// FindTarget.swift // CodeEditSourceEditor // // Created by Khan Winter on 3/10/25. @@ -9,10 +9,10 @@ // to this one? import CodeEditTextView -protocol SearchTarget: AnyObject { +protocol FindTarget: AnyObject { var emphasizeAPI: EmphasizeAPI? { get } var text: String { get } - + var cursorPositions: [CursorPosition] { get } func setCursorPositions(_ positions: [CursorPosition]) func updateCursorPosition() diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift new file mode 100644 index 000000000..8eddc7c6e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -0,0 +1,288 @@ +// +// FindViewController.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/10/25. +// + +import AppKit + +/// Creates a container controller for displaying and hiding a search bar with a content view. +final class FindViewController: NSViewController { + weak var target: FindTarget? + var childView: NSView + var findPanel: FindPanel! + + private var findPanelVerticalConstraint: NSLayoutConstraint! + + private(set) public var isShowingFindPanel: Bool = false + + init(target: FindTarget, childView: NSView) { + self.target = target + self.childView = childView + super.init(nibName: nil, bundle: nil) + self.findPanel = FindPanel(delegate: self, textView: target as? NSView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + super.loadView() + + // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to + // the search bar's bottom + // The search bar is constrained to the top of the view. + // The search bar's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. + // When visible, it's set to 0. + + view.addSubview(findPanel) + view.addSubview(childView) + + // Ensure find panel is always on top + findPanel.wantsLayer = true + findPanel.layer?.zPosition = 1000 + + findPanelVerticalConstraint = findPanel.topAnchor.constraint(equalTo: view.topAnchor) + + NSLayoutConstraint.activate([ + // Constrain search bar + findPanelVerticalConstraint, + findPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + findPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + // Constrain child view + childView.topAnchor.constraint(equalTo: findPanel.bottomAnchor), + childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + override func viewWillAppear() { + super.viewWillAppear() + if isShowingFindPanel { // Update constraints for initial state + showFindPanel() + } else { + hideFindPanel() + } + } +} + +// MARK: - Toggle Search Bar + +extension FindViewController { + /// Toggle the search bar + func toggleFindPanel() { + if isShowingFindPanel { + hideFindPanel() + } else { + showFindPanel() + } + } + + /// Show the search bar + func showFindPanel() { + if !isShowingFindPanel { + isShowingFindPanel = true + withAnimation { + // Update the search bar's top to be equal to the view's top. + findPanelVerticalConstraint.constant = 0 + findPanelVerticalConstraint.isActive = true + } + } + _ = findPanel?.becomeFirstResponder() + } + + /// Hide the search bar + func hideFindPanel() { + isShowingFindPanel = false + _ = findPanel?.resignFirstResponder() + withAnimation { + // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. + findPanelVerticalConstraint.constant = -findPanel.fittingSize.height + findPanelVerticalConstraint.isActive = true + } + } + + /// Runs the `animatable` callback in an animation context with implicit animation enabled. + /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this + /// callback to have them animated. + private func withAnimation(_ animatable: () -> Void) { + NSAnimationContext.runAnimationGroup { animator in + animator.duration = 0.2 + animator.allowsImplicitAnimation = true + + animatable() + + view.updateConstraints() + view.layoutSubtreeIfNeeded() + } + } +} + +// MARK: - Search Bar Delegate + +extension FindViewController: FindPanelDelegate { + func findPanelOnSubmit() { + target?.emphasizeAPI?.highlightNext() + if let textViewController = target as? TextViewController, + let emphasizeAPI = target?.emphasizeAPI, + !emphasizeAPI.emphasizedRanges.isEmpty { + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + let range = emphasizeAPI.emphasizedRanges[activeIndex].range + textViewController.textView.scrollToRange(range) + textViewController.setCursorPositions([CursorPosition(range: range)]) + } + } + + func findPanelOnCancel() { + // Return focus to the editor and restore cursor + if let textViewController = target as? TextViewController { + // Get the current highlight range before doing anything else + var rangeToSelect: NSRange? + if let emphasizeAPI = target?.emphasizeAPI { + if !emphasizeAPI.emphasizedRanges.isEmpty { + // Get the active highlight range + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + rangeToSelect = emphasizeAPI.emphasizedRanges[activeIndex].range + } + } + + // Now hide the panel + if isShowingFindPanel { + hideFindPanel() + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // First make the text view first responder + self.view.window?.makeFirstResponder(textViewController.textView) + + // If we had an active highlight, select it + if let rangeToSelect = rangeToSelect { + // Set the selection first + textViewController.textView.selectionManager.setSelectedRanges([rangeToSelect]) + textViewController.setCursorPositions([CursorPosition(range: rangeToSelect)]) + textViewController.textView.scrollToRange(rangeToSelect) + + // Then clear highlights after a short delay to ensure selection is set + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.target?.emphasizeAPI?.removeEmphasizeLayers() + textViewController.textView.needsDisplay = true + } + } else if let currentPosition = textViewController.cursorPositions.first { + // Otherwise ensure cursor is visible at last position + textViewController.textView.scrollToRange(currentPosition.range) + textViewController.textView.selectionManager.setSelectedRanges([currentPosition.range]) + self.target?.emphasizeAPI?.removeEmphasizeLayers() + } + } + } + } + + func findPanelDidUpdate(_ searchText: String) { + // Only perform search if we're not handling a mouse click in the text view + if let textViewController = target as? TextViewController, + textViewController.textView.window?.firstResponder === textViewController.textView { + // If the text view has focus, just clear emphasis layers without searching + target?.emphasizeAPI?.removeEmphasizeLayers() + findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + return + } + searchFile(query: searchText) + } + + func findPanelPrevButtonClicked() { + target?.emphasizeAPI?.highlightPrevious() + if let textViewController = target as? TextViewController, + let emphasizeAPI = target?.emphasizeAPI, + !emphasizeAPI.emphasizedRanges.isEmpty { + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + let range = emphasizeAPI.emphasizedRanges[activeIndex].range + textViewController.textView.scrollToRange(range) + textViewController.setCursorPositions([CursorPosition(range: range)]) + } + } + + func findPanelNextButtonClicked() { + target?.emphasizeAPI?.highlightNext() + if let textViewController = target as? TextViewController, + let emphasizeAPI = target?.emphasizeAPI, + !emphasizeAPI.emphasizedRanges.isEmpty { + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + let range = emphasizeAPI.emphasizedRanges[activeIndex].range + textViewController.textView.scrollToRange(range) + textViewController.setCursorPositions([CursorPosition(range: range)]) + } + } + + func searchFile(query: String) { + // Don't search if target or emphasizeAPI isn't ready + guard let target = target, + let emphasizeAPI = target.emphasizeAPI else { + findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + return + } + + // Clear highlights and return if query is empty + if query.isEmpty { + emphasizeAPI.removeEmphasizeLayers() + findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + return + } + + let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: query) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions) else { + emphasizeAPI.removeEmphasizeLayers() + findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + return + } + + let text = target.text + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + guard !matches.isEmpty else { + emphasizeAPI.removeEmphasizeLayers() + findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + return + } + + let searchResults = matches.map { $0.range } + findPanel.searchDelegate?.findPanelUpdateMatchCount(searchResults.count) + + // If we have an active highlight and the same number of matches, try to preserve the active index + let currentActiveIndex = target.emphasizeAPI?.emphasizedRangeIndex ?? 0 + let activeIndex = (target.emphasizeAPI?.emphasizedRanges.count == searchResults.count) ? + currentActiveIndex : 0 + + emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) + + // Only set cursor position if we're actively searching (not when clearing) + if !query.isEmpty { + // Always select the active highlight + target.setCursorPositions([CursorPosition(range: searchResults[activeIndex])]) + } + } + + // Only re-serach the part of the file that changed upwards + private func reSearch() { } + + // Returns true if string contains uppercase letter + // used for: ignores letter case if the search query is all lowercase + private func smartCase(str: String) -> Bool { + return str.range(of: "[A-Z]", options: .regularExpression) != nil + } + + func findPanelUpdateMatchCount(_ count: Int) { + findPanel.updateMatchCount(count) + } + + func findPanelClearEmphasis() { + target?.emphasizeAPI?.removeEmphasizeLayers() + findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + } +} diff --git a/Sources/CodeEditSourceEditor/Search/SearchBar.swift b/Sources/CodeEditSourceEditor/Search/SearchBar.swift deleted file mode 100644 index 16e15791e..000000000 --- a/Sources/CodeEditSourceEditor/Search/SearchBar.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// SearchBar.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import AppKit - -protocol SearchBarDelegate: AnyObject { - func searchBarOnSubmit() - func searchBarOnCancel() - func searchBarDidUpdate(_ searchText: String) - func searchBarPrevButtonClicked() - func searchBarNextButtonClicked() -} - -/// A control for searching a document and navigating results. -final class SearchBar: NSStackView { - weak var searchDelegate: SearchBarDelegate? - - var searchField: NSTextField! - var prevButton: NSButton! - var nextButton: NSButton! - - init(delegate: SearchBarDelegate?) { - super.init(frame: .zero) - - self.searchDelegate = delegate - - searchField = NSTextField() - searchField.placeholderString = "Search..." - searchField.controlSize = .regular // TODO: a - searchField.focusRingType = .none - searchField.bezelStyle = .roundedBezel - searchField.drawsBackground = true - searchField.translatesAutoresizingMaskIntoConstraints = false - searchField.action = #selector(onSubmit) - searchField.target = self - - prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) - prevButton.bezelStyle = .texturedRounded - prevButton.controlSize = .small - prevButton.translatesAutoresizingMaskIntoConstraints = false - - nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) - nextButton.bezelStyle = .texturedRounded - nextButton.controlSize = .small - nextButton.translatesAutoresizingMaskIntoConstraints = false - - self.orientation = .horizontal - self.spacing = 8 - self.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) - self.translatesAutoresizingMaskIntoConstraints = false - - self.addView(searchField, in: .leading) - self.addView(prevButton, in: .trailing) - self.addView(nextButton, in: .trailing) - - NotificationCenter.default.addObserver( - self, - selector: #selector(searchFieldUpdated(_:)), - name: NSControl.textDidChangeNotification, - object: searchField - ) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Hide the search bar when escape is pressed - override func cancelOperation(_ sender: Any?) { - searchDelegate?.searchBarOnCancel() - } - - // MARK: - Delegate Messaging - - @objc func searchFieldUpdated(_ notification: Notification) { - guard let searchField = notification.object as? NSTextField else { return } - searchDelegate?.searchBarDidUpdate(searchField.stringValue) - } - - @objc func onSubmit() { - searchDelegate?.searchBarOnSubmit() - } - - @objc func prevButtonClicked() { - searchDelegate?.searchBarPrevButtonClicked() - } - - @objc func nextButtonClicked() { - searchDelegate?.searchBarNextButtonClicked() - } -} diff --git a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift deleted file mode 100644 index c3bb3f540..000000000 --- a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// SearchViewController.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import AppKit - -/// Creates a container controller for displaying and hiding a search bar with a content view. -final class SearchViewController: NSViewController { - weak var target: SearchTarget? - var childView: NSView - var searchBar: SearchBar! - - private var searchBarVerticalConstraint: NSLayoutConstraint! - - private(set) public var isShowingSearchBar: Bool = false - - init(target: SearchTarget, childView: NSView) { - self.target = target - self.childView = childView - super.init(nibName: nil, bundle: nil) - self.searchBar = SearchBar(delegate: self) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - super.loadView() - - // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to - // the search bar's bottom - // The search bar is constrained to the top of the view. - // The search bar's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. - // When visible, it's set to 0. - - view.addSubview(searchBar) - view.addSubview(childView) - - searchBarVerticalConstraint = searchBar.topAnchor.constraint(equalTo: view.topAnchor) - - NSLayoutConstraint.activate([ - // Constrain search bar - searchBarVerticalConstraint, - searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - // Constrain child view - childView.topAnchor.constraint(equalTo: searchBar.bottomAnchor), - childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - } - - override func viewWillAppear() { - super.viewWillAppear() - if isShowingSearchBar { // Update constraints for initial state - showSearchBar() - } else { - hideSearchBar() - } - } -} - -// MARK: - Toggle Search Bar - -extension SearchViewController { - /// Toggle the search bar - func toggleSearchBar() { - if isShowingSearchBar { - hideSearchBar() - } else { - showSearchBar() - } - } - - /// Show the search bar - func showSearchBar() { - isShowingSearchBar = true - _ = searchBar?.searchField.becomeFirstResponder() - withAnimation { - // Update the search bar's top to be equal to the view's top. - searchBarVerticalConstraint.constant = 0 - searchBarVerticalConstraint.isActive = true - } - } - - /// Hide the search bar - func hideSearchBar() { - isShowingSearchBar = false - _ = searchBar?.searchField.resignFirstResponder() - withAnimation { - // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. - searchBarVerticalConstraint.constant = -searchBar.fittingSize.height - searchBarVerticalConstraint.isActive = true - } - } - - /// Runs the `animatable` callback in an animation context with implicit animation enabled. - /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this - /// callback to have them animated. - private func withAnimation(_ animatable: () -> Void) { - NSAnimationContext.runAnimationGroup { animator in - animator.duration = 0.2 - animator.allowsImplicitAnimation = true - - animatable() - - view.updateConstraints() - view.layoutSubtreeIfNeeded() - } - } -} - -// MARK: - Search Bar Delegate - -extension SearchViewController: SearchBarDelegate { - func searchBarOnSubmit() { - target?.emphasizeAPI?.highlightNext() - // if let highlightedRange = target?.emphasizeAPI?.emphasizedRanges[target.emphasizeAPI?.emphasizedRangeIndex ?? 0] { - // target?.setCursorPositions([CursorPosition(range: highlightedRange.range)]) - // target?.updateCursorPosition() - // } - } - - func searchBarOnCancel() { - if isShowingSearchBar { - hideSearchBar() - } - } - - func searchBarDidUpdate(_ searchText: String) { - searchFile(query: searchText) - } - - func searchBarPrevButtonClicked() { - target?.emphasizeAPI?.highlightPrevious() - // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - // textView.scrollToRange(currentRange) - // } - } - - func searchBarNextButtonClicked() { - target?.emphasizeAPI?.highlightNext() - // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - // textView.scrollToRange(currentRange) - // self.gutterView.needsDisplay = true - // } - } - - func searchFile(query: String) { - let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: query) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions), - let text = target?.text else { - target?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - guard !matches.isEmpty else { - target?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let searchResults = matches.map { $0.range } - let bestHighlightIndex = getNearestHighlightIndex(matchRanges: searchResults) ?? 0 - print(searchResults.count) - target?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: 0) - target?.setCursorPositions([CursorPosition(range: searchResults[bestHighlightIndex])]) - } - - private func getNearestHighlightIndex(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-serach the part of the file that changed upwards - private func reSearch() { } - - // Returns true if string contains uppercase letter - // used for: ignores letter case if the search query is all lowercase - private func smartCase(str: String) -> Bool { - return str.range(of: "[A-Z]", options: .regularExpression) != nil - } -} From d62a0c17c52a4df85d4549c67d70998b9609b075 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 13 Mar 2025 01:33:47 -0500 Subject: [PATCH 08/37] Add dark theme to example app --- .../CodeEditSourceEditorExampleApp.swift | 1 - .../Extensions/EditorTheme+Default.swift | 26 ++++- .../Views/ContentView.swift | 110 ++++++++++-------- .../CodeEditUI/PanelStyles.swift | 2 +- .../TextViewController+LoadView.swift | 35 ------ .../HighlightProviderState.swift | 0 .../HighlightProviding.swift | 0 7 files changed, 83 insertions(+), 91 deletions(-) rename Sources/CodeEditSourceEditor/Highlighting/{HighlighProviding => HighlightProviding}/HighlightProviderState.swift (100%) rename Sources/CodeEditSourceEditor/Highlighting/{HighlighProviding => HighlightProviding}/HighlightProviding.swift (100%) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift index 4e8ed5e44..ac078e338 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift @@ -12,7 +12,6 @@ struct CodeEditSourceEditorExampleApp: App { var body: some Scene { DocumentGroup(newDocument: CodeEditSourceEditorExampleDocument()) { file in ContentView(document: file.$document, fileURL: file.fileURL) - .preferredColorScheme(.light) } } } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift index 676eece9a..e55bd05f9 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Extensions/EditorTheme+Default.swift @@ -10,7 +10,7 @@ import AppKit import CodeEditSourceEditor extension EditorTheme { - static var standard: EditorTheme { + static var light: EditorTheme { EditorTheme( text: Attribute(color: NSColor(hex: "000000")), insertionPoint: NSColor(hex: "000000"), @@ -25,9 +25,29 @@ extension EditorTheme { variables: Attribute(color: NSColor(hex: "0F68A0")), values: Attribute(color: NSColor(hex: "6C36A9")), numbers: Attribute(color: NSColor(hex: "1C00CF")), - strings: Attribute(color: NSColor(hex: "C41A16"), bold: true, italic: true), + strings: Attribute(color: NSColor(hex: "C41A16")), characters: Attribute(color: NSColor(hex: "1C00CF")), - comments: Attribute(color: NSColor(hex: "267507"), italic: true) + comments: Attribute(color: NSColor(hex: "267507")) + ) + } + static var dark: EditorTheme { + EditorTheme( + text: Attribute(color: NSColor(hex: "FFFFFF")), + insertionPoint: NSColor(hex: "007AFF"), + invisibles: Attribute(color: NSColor(hex: "53606E")), + background: NSColor(hex: "292A30"), + lineHighlight: NSColor(hex: "2F3239"), + selection: NSColor(hex: "646F83"), + keywords: Attribute(color: NSColor(hex: "FF7AB2"), bold: true), + commands: Attribute(color: NSColor(hex: "78C2B3")), + types: Attribute(color: NSColor(hex: "6BDFFF")), + attributes: Attribute(color: NSColor(hex: "CC9768")), + variables: Attribute(color: NSColor(hex: "4EB0CC")), + values: Attribute(color: NSColor(hex: "B281EB")), + numbers: Attribute(color: NSColor(hex: "D9C97C")), + strings: Attribute(color: NSColor(hex: "FF8170")), + characters: Attribute(color: NSColor(hex: "D9C97C")), + comments: Attribute(color: NSColor(hex: "7F8C98")) ) } } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 9a7fb2ae5..bd543595d 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -11,16 +11,20 @@ import CodeEditLanguages import CodeEditTextView struct ContentView: View { + @Environment(\.colorScheme) + var colorScheme + @Binding var document: CodeEditSourceEditorExampleDocument let fileURL: URL? @State private var language: CodeLanguage = .default - @State private var theme: EditorTheme = .standard + @State private var theme: EditorTheme = .light @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) @AppStorage("wrapLines") private var wrapLines: Bool = true @State private var cursorPositions: [CursorPosition] = [] @AppStorage("systemCursor") private var useSystemCursor: Bool = false @State private var isInLongParse = false + @State private var treeSitterClient = TreeSitterClient() init(document: Binding, fileURL: URL?) { self._document = document @@ -29,68 +33,65 @@ struct ContentView: View { var body: some View { VStack { - ZStack { - if isInLongParse { - VStack { - HStack { - Spacer() - Text("Parsing document...") - Spacer() - } - .padding(4) - .background(Color(NSColor.windowBackgroundColor)) - Spacer() - } - .zIndex(2) - .transition(.opacity) - } - CodeEditSourceEditor( - $document.text, - language: language, - theme: theme, - font: font, - tabWidth: 4, - lineHeight: 1.2, - wrapLines: wrapLines, - cursorPositions: $cursorPositions, - useSystemCursor: useSystemCursor - ) - .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 0) { - Divider() - HStack { - Toggle("Wrap Lines", isOn: $wrapLines) + CodeEditSourceEditor( + $document.text, + language: language, + theme: theme, + font: font, + tabWidth: 4, + lineHeight: 1.2, + wrapLines: wrapLines, + cursorPositions: $cursorPositions, + useThemeBackground: true, + highlightProviders: [treeSitterClient], + useSystemCursor: useSystemCursor + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + Divider() + HStack { + Toggle("Wrap Lines", isOn: $wrapLines) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + if #available(macOS 14, *) { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + } else { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .disabled(true) + .help("macOS 14 required") .toggleStyle(.button) .buttonStyle(.accessoryBar) - if #available(macOS 14, *) { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - } else { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .disabled(true) - .help("macOS 14 required") - .toggleStyle(.button) - .buttonStyle(.accessoryBar) + } + + Spacer() + if isInLongParse { + HStack(spacing: 5) { + ProgressView() + .controlSize(.small) + Text("Parsing Document") } - Spacer() + } else { Text(getLabel(cursorPositions)) - Divider() - .frame(height: 12) - LanguagePicker(language: $language) - .buttonStyle(.borderless) } - .padding(.horizontal, 8) - .frame(height: 28) + Divider() + .frame(height: 12) + LanguagePicker(language: $language) + .buttonStyle(.borderless) } - .background(.bar) - .zIndex(2) + .padding(.horizontal, 8) + .frame(height: 28) } + .background(.bar) + .zIndex(2) } .onAppear { self.language = detectLanguage(fileURL: fileURL) ?? .default + self.theme = colorScheme == .dark ? .dark : .light } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in withAnimation(.easeIn(duration: 0.1)) { isInLongParse = true @@ -101,6 +102,13 @@ struct ContentView: View { isInLongParse = false } } + .onChange(of: colorScheme) { _, newValue in + if newValue == .dark { + theme = .dark + } else { + theme = .light + } + } } private func detectLanguage(fileURL: URL?) -> CodeLanguage? { diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift b/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift index ce3e3108e..b836c5307 100644 --- a/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift +++ b/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift @@ -68,4 +68,4 @@ struct PanelButtonStyle: ButtonStyle { .clipped() .contentShape(Rectangle()) } -} +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index a4c8202e1..799846917 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -157,41 +157,6 @@ extension TextViewController { } } - /// Handles the tab key event. - /// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines - /// are highlighted and handles indenting accordingly. - /// - /// - Returns: The original event if it should be passed on, or `nil` to indicate handling within the method. - func handleTab(event: NSEvent, modifierFalgs: UInt) -> NSEvent? { - let shiftKey = NSEvent.ModifierFlags.shift.rawValue - - if modifierFalgs == shiftKey { - handleIndent(inwards: true) - } else { - // Only allow tab to work if multiple lines are selected - guard multipleLinesHighlighted() else { return event } - handleIndent() - } - return nil - } - func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { - let commandKey = NSEvent.ModifierFlags.command.rawValue - - switch (modifierFlags, event.charactersIgnoringModifiers) { - case (commandKey, "/"): - handleCommandSlash() - return nil - case (commandKey, "["): - handleIndent(inwards: true) - return nil - case (commandKey, "]"): - handleIndent() - return nil - case (_, _): - return event - } - } - /// Handles the tab key event. /// If the Shift key is pressed, it handles unindenting. If no modifier key is pressed, it checks if multiple lines /// are highlighted and handles indenting accordingly. diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviderState.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift diff --git a/Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviding.swift b/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviding.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Highlighting/HighlighProviding/HighlightProviding.swift rename to Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviding.swift From 1f9beb62927f7630fb2ee12664dedf08f50774eb Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 13 Mar 2025 10:46:27 -0500 Subject: [PATCH 09/37] Fixed background --- .../Controller/TextViewController+StyleViews.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index cd12c07a5..40928a39b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -23,6 +23,7 @@ extension TextViewController { textView.selectionManager.selectedLineBackgroundColor = getThemeBackground() textView.selectionManager.highlightSelectedLine = isEditable textView.selectionManager.insertionPointColor = theme.insertionPoint + textView.enclosingScrollView?.backgroundColor = useThemeBackground ? theme.background : .clear paragraphStyle = generateParagraphStyle() textView.typingAttributes = attributesFor(nil) } @@ -49,7 +50,7 @@ extension TextViewController { : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) gutterView.highlightSelectedLines = isEditable gutterView.font = font.rulerFont - gutterView.backgroundColor = useThemeBackground ? theme.background : .textBackgroundColor + gutterView.backgroundColor = useThemeBackground ? theme.background : .windowBackgroundColor if self.isEditable == false { gutterView.selectedLineTextColor = nil gutterView.selectedLineColor = .clear @@ -59,8 +60,6 @@ extension TextViewController { /// Style the scroll view. package func styleScrollView() { guard let scrollView = view as? NSScrollView else { return } - scrollView.drawsBackground = useThemeBackground - scrollView.backgroundColor = useThemeBackground ? theme.background : .clear if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets From ae899667c6a51cbfb31df61b07812d63e2e93cba Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 13 Mar 2025 12:09:03 -0500 Subject: [PATCH 10/37] Adjusted font in example app --- .../Views/ContentView.swift | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index bd543595d..6468d8441 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -66,20 +66,26 @@ struct ContentView: View { } Spacer() - if isInLongParse { - HStack(spacing: 5) { - ProgressView() - .controlSize(.small) - Text("Parsing Document") + Group { + if isInLongParse { + HStack(spacing: 5) { + ProgressView() + .controlSize(.small) + Text("Parsing Document") + } + } else { + Text(getLabel(cursorPositions)) } - } else { - Text(getLabel(cursorPositions)) } + .foregroundStyle(.secondary) Divider() .frame(height: 12) LanguagePicker(language: $language) .buttonStyle(.borderless) } + .font(.subheadline) + .fontWeight(.medium) + .controlSize(.small) .padding(.horizontal, 8) .frame(height: 28) } From 41d718031a686ac4e31f97df5db6e2630b398cd8 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Fri, 14 Mar 2025 13:04:23 -0500 Subject: [PATCH 11/37] Simplified example app --- .../Views/ContentView.swift | 115 +++++++++--------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 6468d8441..fc78bb1e0 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -32,66 +32,71 @@ struct ContentView: View { } var body: some View { - VStack { - CodeEditSourceEditor( - $document.text, - language: language, - theme: theme, - font: font, - tabWidth: 4, - lineHeight: 1.2, - wrapLines: wrapLines, - cursorPositions: $cursorPositions, - useThemeBackground: true, - highlightProviders: [treeSitterClient], - useSystemCursor: useSystemCursor - ) - .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 0) { - Divider() - HStack { - Toggle("Wrap Lines", isOn: $wrapLines) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - if #available(macOS 14, *) { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - } else { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .disabled(true) - .help("macOS 14 required") - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - } + CodeEditSourceEditor( + $document.text, + language: language, + theme: theme, + font: font, + tabWidth: 4, + lineHeight: 1.2, + wrapLines: wrapLines, + cursorPositions: $cursorPositions, + useThemeBackground: true, + highlightProviders: [treeSitterClient], + useSystemCursor: useSystemCursor + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + HStack { + Toggle("Wrap Lines", isOn: $wrapLines) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + if #available(macOS 14, *) { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + } else { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .disabled(true) + .help("macOS 14 required") + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + } - Spacer() - Group { - if isInLongParse { - HStack(spacing: 5) { - ProgressView() - .controlSize(.small) - Text("Parsing Document") - } - } else { - Text(getLabel(cursorPositions)) - } + Spacer() + Group { + if isInLongParse { + HStack(spacing: 5) { + ProgressView() + .controlSize(.small) + Text("Parsing Document") } - .foregroundStyle(.secondary) - Divider() - .frame(height: 12) - LanguagePicker(language: $language) - .buttonStyle(.borderless) + } else { + Text(getLabel(cursorPositions)) } - .font(.subheadline) - .fontWeight(.medium) - .controlSize(.small) - .padding(.horizontal, 8) - .frame(height: 28) } - .background(.bar) - .zIndex(2) + .foregroundStyle(.secondary) + Divider() + .frame(height: 12) + LanguagePicker(language: $language) + .buttonStyle(.borderless) + } + .font(.subheadline) + .fontWeight(.medium) + .controlSize(.small) + .padding(.horizontal, 8) + .frame(height: 28) + .background(.bar) + .overlay(alignment: .top) { + VStack { + Divider() + .overlay { + if colorScheme == .dark { + Color.black + } + } + } } + .zIndex(2) .onAppear { self.language = detectLanguage(fileURL: fileURL) ?? .default self.theme = colorScheme == .dark ? .dark : .light From 8a028a3beb9d24d5afcfce3cf16321891b6e7e68 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 16 Mar 2025 12:30:44 -0500 Subject: [PATCH 12/37] Fix Text Scrolling Under Panel, Fix Resizing Bug, Fix Test --- .../CodeEditSourceEditorExampleApp.swift | 6 + .../Views/ContentView.swift | 154 ++++++------ .../TextViewController+FindPanelTarget.swift | 25 ++ .../TextViewController+LoadView.swift | 7 - .../TextViewController+StyleViews.swift | 10 +- .../TextViewController+SearchTarget.swift | 14 -- ...FindTarget.swift => FindPanelTarget.swift} | 9 +- .../Find/FindViewController.swift | 219 +++++++++++------ .../Find/{ => PanelView}/FindPanel.swift | 2 + .../Find/{ => PanelView}/FindPanelView.swift | 30 +-- .../{ => PanelView}/FindPanelViewModel.swift | 0 .../Search/SearchBar.swift | 95 -------- .../Search/SearchTarget.swift | 19 -- .../Search/SearchViewController.swift | 226 ------------------ .../TextViewController+SearchTarget.swift | 14 -- .../TextViewControllerTests.swift | 7 +- 16 files changed, 292 insertions(+), 545 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift delete mode 100644 Sources/CodeEditSourceEditor/Extensions/TextViewController+SearchTarget.swift rename Sources/CodeEditSourceEditor/Find/{FindTarget.swift => FindPanelTarget.swift} (71%) rename Sources/CodeEditSourceEditor/Find/{ => PanelView}/FindPanel.swift (98%) rename Sources/CodeEditSourceEditor/Find/{ => PanelView}/FindPanelView.swift (80%) rename Sources/CodeEditSourceEditor/Find/{ => PanelView}/FindPanelViewModel.swift (100%) delete mode 100644 Sources/CodeEditSourceEditor/Search/SearchBar.swift delete mode 100644 Sources/CodeEditSourceEditor/Search/SearchTarget.swift delete mode 100644 Sources/CodeEditSourceEditor/Search/SearchViewController.swift delete mode 100644 Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift index ac078e338..5b4ad8315 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift @@ -12,6 +12,12 @@ struct CodeEditSourceEditorExampleApp: App { var body: some Scene { DocumentGroup(newDocument: CodeEditSourceEditorExampleDocument()) { file in ContentView(document: file.$document, fileURL: file.fileURL) + .toolbar { + Button("Toolbar Item") { + print("Toolbar Item Pressed") + } + } } + .windowToolbarStyle(.unifiedCompact) } } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index fc78bb1e0..aa443ac7a 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -32,92 +32,96 @@ struct ContentView: View { } var body: some View { - CodeEditSourceEditor( - $document.text, - language: language, - theme: theme, - font: font, - tabWidth: 4, - lineHeight: 1.2, - wrapLines: wrapLines, - cursorPositions: $cursorPositions, - useThemeBackground: true, - highlightProviders: [treeSitterClient], - useSystemCursor: useSystemCursor - ) - .safeAreaInset(edge: .bottom, spacing: 0) { - HStack { - Toggle("Wrap Lines", isOn: $wrapLines) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - if #available(macOS 14, *) { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - } else { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .disabled(true) - .help("macOS 14 required") + GeometryReader { proxy in + CodeEditSourceEditor( + $document.text, + language: language, + theme: theme, + font: font, + tabWidth: 4, + lineHeight: 1.2, + wrapLines: wrapLines, + cursorPositions: $cursorPositions, + useThemeBackground: true, + highlightProviders: [treeSitterClient], + contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 0, right: 0), + useSystemCursor: useSystemCursor + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + HStack { + Toggle("Wrap Lines", isOn: $wrapLines) .toggleStyle(.button) .buttonStyle(.accessoryBar) - } + if #available(macOS 14, *) { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + } else { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .disabled(true) + .help("macOS 14 required") + .toggleStyle(.button) + .buttonStyle(.accessoryBar) + } - Spacer() - Group { - if isInLongParse { - HStack(spacing: 5) { - ProgressView() - .controlSize(.small) - Text("Parsing Document") + Spacer() + Group { + if isInLongParse { + HStack(spacing: 5) { + ProgressView() + .controlSize(.small) + Text("Parsing Document") + } + } else { + Text(getLabel(cursorPositions)) } - } else { - Text(getLabel(cursorPositions)) } - } - .foregroundStyle(.secondary) - Divider() - .frame(height: 12) - LanguagePicker(language: $language) - .buttonStyle(.borderless) - } - .font(.subheadline) - .fontWeight(.medium) - .controlSize(.small) - .padding(.horizontal, 8) - .frame(height: 28) - .background(.bar) - .overlay(alignment: .top) { - VStack { + .foregroundStyle(.secondary) Divider() - .overlay { - if colorScheme == .dark { - Color.black + .frame(height: 12) + LanguagePicker(language: $language) + .buttonStyle(.borderless) + } + .font(.subheadline) + .fontWeight(.medium) + .controlSize(.small) + .padding(.horizontal, 8) + .frame(height: 28) + .background(.bar) + .overlay(alignment: .top) { + VStack { + Divider() + .overlay { + if colorScheme == .dark { + Color.black + } } - } + } + } + .zIndex(2) + .onAppear { + self.language = detectLanguage(fileURL: fileURL) ?? .default + self.theme = colorScheme == .dark ? .dark : .light } } - .zIndex(2) - .onAppear { - self.language = detectLanguage(fileURL: fileURL) ?? .default - self.theme = colorScheme == .dark ? .dark : .light - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in - withAnimation(.easeIn(duration: 0.1)) { - isInLongParse = true + .ignoresSafeArea() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParse)) { _ in + withAnimation(.easeIn(duration: 0.1)) { + isInLongParse = true + } } - } - .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParseFinished)) { _ in - withAnimation(.easeIn(duration: 0.1)) { - isInLongParse = false + .onReceive(NotificationCenter.default.publisher(for: TreeSitterClient.Constants.longParseFinished)) { _ in + withAnimation(.easeIn(duration: 0.1)) { + isInLongParse = false + } } - } - .onChange(of: colorScheme) { _, newValue in - if newValue == .dark { - theme = .dark - } else { - theme = .light + .onChange(of: colorScheme) { _, newValue in + if newValue == .dark { + theme = .dark + } else { + theme = .light + } } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift new file mode 100644 index 000000000..3230ecd06 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -0,0 +1,25 @@ +// +// TextViewController+FindPanelTarget.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 3/16/25. +// + +import Foundation +import CodeEditTextView + +extension TextViewController: FindPanelTarget { + func findPanelWillShow(panelHeight: CGFloat) { + scrollView.contentInsets.top += panelHeight + gutterView.frame.origin.y = -scrollView.contentInsets.top + } + + func findPanelWillHide(panelHeight: CGFloat) { + scrollView.contentInsets.top -= panelHeight + gutterView.frame.origin.y = -scrollView.contentInsets.top + } + + var emphasizeAPI: EmphasizeAPI? { + textView?.emphasizeAPI + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 799846917..2dca6755f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -14,14 +14,7 @@ extension TextViewController { super.loadView() scrollView = NSScrollView() - textView.postsFrameChangedNotifications = true - textView.translatesAutoresizingMaskIntoConstraints = false - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.contentView.postsFrameChangedNotifications = true - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = !wrapLines scrollView.documentView = textView - scrollView.contentView.postsBoundsChangedNotifications = true gutterView = GutterView( font: font.rulerFont, diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 40928a39b..fbf70c5bc 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -19,6 +19,8 @@ extension TextViewController { /// Style the text view. package func styleTextView() { + textView.postsFrameChangedNotifications = true + textView.translatesAutoresizingMaskIntoConstraints = false textView.selectionManager.selectionBackgroundColor = theme.selection textView.selectionManager.selectedLineBackgroundColor = getThemeBackground() textView.selectionManager.highlightSelectedLine = isEditable @@ -44,6 +46,7 @@ extension TextViewController { /// Style the gutter view. package func styleGutterView() { + // Note: If changing this value, also change in ``findPanelWillShow/Hide()`` gutterView.frame.origin.y = -scrollView.contentInsets.top gutterView.selectedLineColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua ? NSColor.quaternaryLabelColor @@ -59,7 +62,12 @@ extension TextViewController { /// Style the scroll view. package func styleScrollView() { - guard let scrollView = view as? NSScrollView else { return } + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.contentView.postsFrameChangedNotifications = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = !wrapLines + + scrollView.contentView.postsBoundsChangedNotifications = true if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets diff --git a/Sources/CodeEditSourceEditor/Extensions/TextViewController+SearchTarget.swift b/Sources/CodeEditSourceEditor/Extensions/TextViewController+SearchTarget.swift deleted file mode 100644 index abe90f30b..000000000 --- a/Sources/CodeEditSourceEditor/Extensions/TextViewController+SearchTarget.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// TextViewController.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/11/25. -// - -import CodeEditTextView - -extension TextViewController: FindTarget { - var emphasizeAPI: EmphasizeAPI? { - textView?.emphasizeAPI - } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift similarity index 71% rename from Sources/CodeEditSourceEditor/Find/FindTarget.swift rename to Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index e6b0c7cf1..3a01c4094 100644 --- a/Sources/CodeEditSourceEditor/Find/FindTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -1,19 +1,24 @@ // -// FindTarget.swift +// FindPanelTarget.swift // CodeEditSourceEditor // // Created by Khan Winter on 3/10/25. // +import Foundation + // This dependency is not ideal, maybe we could make this another protocol that the emphasize API conforms to similar // to this one? import CodeEditTextView -protocol FindTarget: AnyObject { +protocol FindPanelTarget: AnyObject { var emphasizeAPI: EmphasizeAPI? { get } var text: String { get } var cursorPositions: [CursorPosition] { get } func setCursorPositions(_ positions: [CursorPosition]) func updateCursorPosition() + + func findPanelWillShow(panelHeight: CGFloat) + func findPanelWillHide(panelHeight: CGFloat) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 8eddc7c6e..1c13936a7 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -9,7 +9,7 @@ import AppKit /// Creates a container controller for displaying and hiding a search bar with a content view. final class FindViewController: NSViewController { - weak var target: FindTarget? + weak var target: FindPanelTarget? var childView: NSView var findPanel: FindPanel! @@ -17,7 +17,7 @@ final class FindViewController: NSViewController { private(set) public var isShowingFindPanel: Bool = false - init(target: FindTarget, childView: NSView) { + init(target: FindPanelTarget, childView: NSView) { self.target = target self.childView = childView super.init(nibName: nil, bundle: nil) @@ -37,6 +37,7 @@ final class FindViewController: NSViewController { // The search bar's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. // When visible, it's set to 0. + view.clipsToBounds = false view.addSubview(findPanel) view.addSubview(childView) @@ -53,7 +54,7 @@ final class FindViewController: NSViewController { findPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor), // Constrain child view - childView.topAnchor.constraint(equalTo: findPanel.bottomAnchor), + childView.topAnchor.constraint(equalTo: view.topAnchor), childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) @@ -63,11 +64,31 @@ final class FindViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() if isShowingFindPanel { // Update constraints for initial state - showFindPanel() + setFindPanelConstraintShow() } else { - hideFindPanel() + setFindPanelConstraintHide() } } + + /// Sets the find panel constraint to show the find panel. + /// Can be animated using implicit animation. + private func setFindPanelConstraintShow() { + // Update the search bar's top to be equal to the view's top. + findPanelVerticalConstraint.constant = view.safeAreaInsets.top + findPanelVerticalConstraint.isActive = true + } + + /// Sets the find panel constraint to hide the find panel. + /// Can be animated using implicit animation. + private func setFindPanelConstraintHide() { + // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. + + // 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 = view.safeAreaInsets.top - (FindPanel.height * 3) + findPanelVerticalConstraint.isActive = true + } } // MARK: - Toggle Search Bar @@ -83,26 +104,40 @@ extension FindViewController { } /// Show the search bar - func showFindPanel() { - if !isShowingFindPanel { - isShowingFindPanel = true - withAnimation { - // Update the search bar's top to be equal to the view's top. - findPanelVerticalConstraint.constant = 0 - findPanelVerticalConstraint.isActive = true - } + func showFindPanel(animated: Bool = true) { + guard !isShowingFindPanel else { return } + isShowingFindPanel = true + + let updates: () -> Void = { [self] in + // 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) + setFindPanelConstraintShow() } + + if animated { + withAnimation(updates) + } else { + updates() + } + _ = findPanel?.becomeFirstResponder() } /// Hide the search bar - func hideFindPanel() { + func hideFindPanel(animated: Bool = true) { isShowingFindPanel = false _ = findPanel?.resignFirstResponder() - withAnimation { - // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. - findPanelVerticalConstraint.constant = -findPanel.fittingSize.height - findPanelVerticalConstraint.isActive = true + + let updates: () -> Void = { [self] in + target?.findPanelWillHide(panelHeight: FindPanel.height) + setFindPanelConstraintHide() + } + + if animated { + withAnimation(updates) + } else { + updates() } } @@ -127,28 +162,28 @@ extension FindViewController { extension FindViewController: FindPanelDelegate { func findPanelOnSubmit() { target?.emphasizeAPI?.highlightNext() - if let textViewController = target as? TextViewController, - let emphasizeAPI = target?.emphasizeAPI, - !emphasizeAPI.emphasizedRanges.isEmpty { - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - let range = emphasizeAPI.emphasizedRanges[activeIndex].range - textViewController.textView.scrollToRange(range) - textViewController.setCursorPositions([CursorPosition(range: range)]) - } +// if let textViewController = target as? TextViewController, +// let emphasizeAPI = target?.emphasizeAPI, +// !emphasizeAPI.emphasizedRanges.isEmpty { +// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 +// let range = emphasizeAPI.emphasizedRanges[activeIndex].range +// textViewController.textView.scrollToRange(range) +// textViewController.setCursorPositions([CursorPosition(range: range)]) +// } } func findPanelOnCancel() { // Return focus to the editor and restore cursor if let textViewController = target as? TextViewController { // Get the current highlight range before doing anything else - var rangeToSelect: NSRange? - if let emphasizeAPI = target?.emphasizeAPI { - if !emphasizeAPI.emphasizedRanges.isEmpty { - // Get the active highlight range - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - rangeToSelect = emphasizeAPI.emphasizedRanges[activeIndex].range - } - } +// var rangeToSelect: NSRange? +// if let emphasizeAPI = target?.emphasizeAPI { +// if !emphasizeAPI.emphasizedRanges.isEmpty { +// // Get the active highlight range +// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 +// rangeToSelect = emphasizeAPI.emphasizedRanges[activeIndex].range +// } +// } // Now hide the panel if isShowingFindPanel { @@ -162,23 +197,23 @@ extension FindViewController: FindPanelDelegate { self.view.window?.makeFirstResponder(textViewController.textView) // If we had an active highlight, select it - if let rangeToSelect = rangeToSelect { - // Set the selection first - textViewController.textView.selectionManager.setSelectedRanges([rangeToSelect]) - textViewController.setCursorPositions([CursorPosition(range: rangeToSelect)]) - textViewController.textView.scrollToRange(rangeToSelect) - - // Then clear highlights after a short delay to ensure selection is set - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.target?.emphasizeAPI?.removeEmphasizeLayers() - textViewController.textView.needsDisplay = true - } - } else if let currentPosition = textViewController.cursorPositions.first { - // Otherwise ensure cursor is visible at last position - textViewController.textView.scrollToRange(currentPosition.range) - textViewController.textView.selectionManager.setSelectedRanges([currentPosition.range]) - self.target?.emphasizeAPI?.removeEmphasizeLayers() - } +// if let rangeToSelect = rangeToSelect { +// // Set the selection first +// textViewController.textView.selectionManager.setSelectedRanges([rangeToSelect]) +// textViewController.setCursorPositions([CursorPosition(range: rangeToSelect)]) +// textViewController.textView.scrollToRange(rangeToSelect) +// +// // Then clear highlights after a short delay to ensure selection is set +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { +// self.target?.emphasizeAPI?.removeEmphasizeLayers() +// textViewController.textView.needsDisplay = true +// } +// } else if let currentPosition = textViewController.cursorPositions.first { +// // Otherwise ensure cursor is visible at last position +// textViewController.textView.scrollToRange(currentPosition.range) +// textViewController.textView.selectionManager.setSelectedRanges([currentPosition.range]) +// self.target?.emphasizeAPI?.removeEmphasizeLayers() +// } } } } @@ -197,26 +232,26 @@ extension FindViewController: FindPanelDelegate { func findPanelPrevButtonClicked() { target?.emphasizeAPI?.highlightPrevious() - if let textViewController = target as? TextViewController, - let emphasizeAPI = target?.emphasizeAPI, - !emphasizeAPI.emphasizedRanges.isEmpty { - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - let range = emphasizeAPI.emphasizedRanges[activeIndex].range - textViewController.textView.scrollToRange(range) - textViewController.setCursorPositions([CursorPosition(range: range)]) - } +// if let textViewController = target as? TextViewController, +// let emphasizeAPI = target?.emphasizeAPI, +// !emphasizeAPI.emphasizedRanges.isEmpty { +// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 +// let range = emphasizeAPI.emphasizedRanges[activeIndex].range +// textViewController.textView.scrollToRange(range) +// textViewController.setCursorPositions([CursorPosition(range: range)]) +// } } func findPanelNextButtonClicked() { target?.emphasizeAPI?.highlightNext() - if let textViewController = target as? TextViewController, - let emphasizeAPI = target?.emphasizeAPI, - !emphasizeAPI.emphasizedRanges.isEmpty { - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - let range = emphasizeAPI.emphasizedRanges[activeIndex].range - textViewController.textView.scrollToRange(range) - textViewController.setCursorPositions([CursorPosition(range: range)]) - } +// if let textViewController = target as? TextViewController, +// let emphasizeAPI = target?.emphasizeAPI, +// !emphasizeAPI.emphasizedRanges.isEmpty { +// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 +// let range = emphasizeAPI.emphasizedRanges[activeIndex].range +// textViewController.textView.scrollToRange(range) +// textViewController.setCursorPositions([CursorPosition(range: range)]) +// } } func searchFile(query: String) { @@ -255,17 +290,57 @@ extension FindViewController: FindPanelDelegate { findPanel.searchDelegate?.findPanelUpdateMatchCount(searchResults.count) // If we have an active highlight and the same number of matches, try to preserve the active index - let currentActiveIndex = target.emphasizeAPI?.emphasizedRangeIndex ?? 0 - let activeIndex = (target.emphasizeAPI?.emphasizedRanges.count == searchResults.count) ? - currentActiveIndex : 0 +// let currentActiveIndex = target.emphasizeAPI?.emphasizedRangeIndex ?? 0 +// let activeIndex = (target.emphasizeAPI?.emphasizedRanges.count == searchResults.count) ? +// currentActiveIndex : 0 - emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) +// emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) // Only set cursor position if we're actively searching (not when clearing) if !query.isEmpty { // Always select the active highlight - target.setCursorPositions([CursorPosition(range: searchResults[activeIndex])]) +// target.setCursorPositions([CursorPosition(range: searchResults[activeIndex])]) + } + } + + private func getNearestHighlightIndex(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-serach the part of the file that changed upwards diff --git a/Sources/CodeEditSourceEditor/Find/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift similarity index 98% rename from Sources/CodeEditSourceEditor/Find/FindPanel.swift rename to Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index 1d8f8b9aa..887eb2f96 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -11,6 +11,8 @@ import Combine // NSView wrapper for using SwiftUI view in AppKit final class FindPanel: NSView { + static let height: CGFloat = 28 + weak var searchDelegate: FindPanelDelegate? private var hostingView: NSHostingView! private var viewModel: FindPanelViewModel! diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift similarity index 80% rename from Sources/CodeEditSourceEditor/Find/FindPanelView.swift rename to Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index d750224dd..dba32d5d6 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -26,23 +26,23 @@ struct FindPanelView: View { .frame(width: 16, height: 20) }, helperText: viewModel.searchText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", clearable: true ) - .focused($isFocused) - .onChange(of: viewModel.isFocused) { newValue in - isFocused = newValue - if !newValue { - viewModel.removeEmphasis() - } - } - .onChange(of: isFocused) { newValue in - viewModel.setFocus(newValue) - } - .onSubmit { - viewModel.onSubmit() + .focused($isFocused) + .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) { @@ -70,7 +70,7 @@ struct FindPanelView: View { } } .padding(.horizontal, 5) - .frame(minHeight: 28) + .frame(height: FindPanel.height) .background(.bar) .onAppear { NSEvent.addLocalMonitorForEvents(matching: .keyDown) { (event) -> NSEvent? in diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Find/FindPanelViewModel.swift rename to Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift diff --git a/Sources/CodeEditSourceEditor/Search/SearchBar.swift b/Sources/CodeEditSourceEditor/Search/SearchBar.swift deleted file mode 100644 index 16e15791e..000000000 --- a/Sources/CodeEditSourceEditor/Search/SearchBar.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// SearchBar.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import AppKit - -protocol SearchBarDelegate: AnyObject { - func searchBarOnSubmit() - func searchBarOnCancel() - func searchBarDidUpdate(_ searchText: String) - func searchBarPrevButtonClicked() - func searchBarNextButtonClicked() -} - -/// A control for searching a document and navigating results. -final class SearchBar: NSStackView { - weak var searchDelegate: SearchBarDelegate? - - var searchField: NSTextField! - var prevButton: NSButton! - var nextButton: NSButton! - - init(delegate: SearchBarDelegate?) { - super.init(frame: .zero) - - self.searchDelegate = delegate - - searchField = NSTextField() - searchField.placeholderString = "Search..." - searchField.controlSize = .regular // TODO: a - searchField.focusRingType = .none - searchField.bezelStyle = .roundedBezel - searchField.drawsBackground = true - searchField.translatesAutoresizingMaskIntoConstraints = false - searchField.action = #selector(onSubmit) - searchField.target = self - - prevButton = NSButton(title: "◀︎", target: self, action: #selector(prevButtonClicked)) - prevButton.bezelStyle = .texturedRounded - prevButton.controlSize = .small - prevButton.translatesAutoresizingMaskIntoConstraints = false - - nextButton = NSButton(title: "▶︎", target: self, action: #selector(nextButtonClicked)) - nextButton.bezelStyle = .texturedRounded - nextButton.controlSize = .small - nextButton.translatesAutoresizingMaskIntoConstraints = false - - self.orientation = .horizontal - self.spacing = 8 - self.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) - self.translatesAutoresizingMaskIntoConstraints = false - - self.addView(searchField, in: .leading) - self.addView(prevButton, in: .trailing) - self.addView(nextButton, in: .trailing) - - NotificationCenter.default.addObserver( - self, - selector: #selector(searchFieldUpdated(_:)), - name: NSControl.textDidChangeNotification, - object: searchField - ) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Hide the search bar when escape is pressed - override func cancelOperation(_ sender: Any?) { - searchDelegate?.searchBarOnCancel() - } - - // MARK: - Delegate Messaging - - @objc func searchFieldUpdated(_ notification: Notification) { - guard let searchField = notification.object as? NSTextField else { return } - searchDelegate?.searchBarDidUpdate(searchField.stringValue) - } - - @objc func onSubmit() { - searchDelegate?.searchBarOnSubmit() - } - - @objc func prevButtonClicked() { - searchDelegate?.searchBarPrevButtonClicked() - } - - @objc func nextButtonClicked() { - searchDelegate?.searchBarNextButtonClicked() - } -} diff --git a/Sources/CodeEditSourceEditor/Search/SearchTarget.swift b/Sources/CodeEditSourceEditor/Search/SearchTarget.swift deleted file mode 100644 index e00682603..000000000 --- a/Sources/CodeEditSourceEditor/Search/SearchTarget.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// SearchTarget.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -// This dependency is not ideal, maybe we could make this another protocol that the emphasize API conforms to similar -// to this one? -import CodeEditTextView - -protocol SearchTarget: AnyObject { - var emphasizeAPI: EmphasizeAPI? { get } - var text: String { get } - - var cursorPositions: [CursorPosition] { get } - func setCursorPositions(_ positions: [CursorPosition]) - func updateCursorPosition() -} diff --git a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift b/Sources/CodeEditSourceEditor/Search/SearchViewController.swift deleted file mode 100644 index c3bb3f540..000000000 --- a/Sources/CodeEditSourceEditor/Search/SearchViewController.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// SearchViewController.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import AppKit - -/// Creates a container controller for displaying and hiding a search bar with a content view. -final class SearchViewController: NSViewController { - weak var target: SearchTarget? - var childView: NSView - var searchBar: SearchBar! - - private var searchBarVerticalConstraint: NSLayoutConstraint! - - private(set) public var isShowingSearchBar: Bool = false - - init(target: SearchTarget, childView: NSView) { - self.target = target - self.childView = childView - super.init(nibName: nil, bundle: nil) - self.searchBar = SearchBar(delegate: self) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - super.loadView() - - // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to - // the search bar's bottom - // The search bar is constrained to the top of the view. - // The search bar's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. - // When visible, it's set to 0. - - view.addSubview(searchBar) - view.addSubview(childView) - - searchBarVerticalConstraint = searchBar.topAnchor.constraint(equalTo: view.topAnchor) - - NSLayoutConstraint.activate([ - // Constrain search bar - searchBarVerticalConstraint, - searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - // Constrain child view - childView.topAnchor.constraint(equalTo: searchBar.bottomAnchor), - childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - childView.trailingAnchor.constraint(equalTo: view.trailingAnchor) - ]) - } - - override func viewWillAppear() { - super.viewWillAppear() - if isShowingSearchBar { // Update constraints for initial state - showSearchBar() - } else { - hideSearchBar() - } - } -} - -// MARK: - Toggle Search Bar - -extension SearchViewController { - /// Toggle the search bar - func toggleSearchBar() { - if isShowingSearchBar { - hideSearchBar() - } else { - showSearchBar() - } - } - - /// Show the search bar - func showSearchBar() { - isShowingSearchBar = true - _ = searchBar?.searchField.becomeFirstResponder() - withAnimation { - // Update the search bar's top to be equal to the view's top. - searchBarVerticalConstraint.constant = 0 - searchBarVerticalConstraint.isActive = true - } - } - - /// Hide the search bar - func hideSearchBar() { - isShowingSearchBar = false - _ = searchBar?.searchField.resignFirstResponder() - withAnimation { - // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. - searchBarVerticalConstraint.constant = -searchBar.fittingSize.height - searchBarVerticalConstraint.isActive = true - } - } - - /// Runs the `animatable` callback in an animation context with implicit animation enabled. - /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this - /// callback to have them animated. - private func withAnimation(_ animatable: () -> Void) { - NSAnimationContext.runAnimationGroup { animator in - animator.duration = 0.2 - animator.allowsImplicitAnimation = true - - animatable() - - view.updateConstraints() - view.layoutSubtreeIfNeeded() - } - } -} - -// MARK: - Search Bar Delegate - -extension SearchViewController: SearchBarDelegate { - func searchBarOnSubmit() { - target?.emphasizeAPI?.highlightNext() - // if let highlightedRange = target?.emphasizeAPI?.emphasizedRanges[target.emphasizeAPI?.emphasizedRangeIndex ?? 0] { - // target?.setCursorPositions([CursorPosition(range: highlightedRange.range)]) - // target?.updateCursorPosition() - // } - } - - func searchBarOnCancel() { - if isShowingSearchBar { - hideSearchBar() - } - } - - func searchBarDidUpdate(_ searchText: String) { - searchFile(query: searchText) - } - - func searchBarPrevButtonClicked() { - target?.emphasizeAPI?.highlightPrevious() - // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - // textView.scrollToRange(currentRange) - // } - } - - func searchBarNextButtonClicked() { - target?.emphasizeAPI?.highlightNext() - // if let currentRange = textView.emphasizeAPI?.emphasizedRanges[(textView.emphasizeAPI?.emphasizedRangeIndex) ?? 0].range { - // textView.scrollToRange(currentRange) - // self.gutterView.needsDisplay = true - // } - } - - func searchFile(query: String) { - let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: query) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions), - let text = target?.text else { - target?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - guard !matches.isEmpty else { - target?.emphasizeAPI?.removeEmphasizeLayers() - return - } - - let searchResults = matches.map { $0.range } - let bestHighlightIndex = getNearestHighlightIndex(matchRanges: searchResults) ?? 0 - print(searchResults.count) - target?.emphasizeAPI?.emphasizeRanges(ranges: searchResults, activeIndex: 0) - target?.setCursorPositions([CursorPosition(range: searchResults[bestHighlightIndex])]) - } - - private func getNearestHighlightIndex(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-serach the part of the file that changed upwards - private func reSearch() { } - - // Returns true if string contains uppercase letter - // used for: ignores letter case if the search query is all lowercase - private func smartCase(str: String) -> Bool { - return str.range(of: "[A-Z]", options: .regularExpression) != nil - } -} diff --git a/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift b/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift deleted file mode 100644 index e6e9b731a..000000000 --- a/Sources/CodeEditSourceEditor/Search/TextViewController+SearchTarget.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// File.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/11/25. -// - -import CodeEditTextView - -extension TextViewController: SearchTarget { - var emphasizeAPI: EmphasizeAPI? { - textView?.emphasizeAPI - } -} diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index ac7f4ecac..cf4139df9 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -66,11 +66,8 @@ final class TextViewControllerTests: XCTestCase { // MARK: Overscroll func test_editorOverScroll() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) - scrollView.frame = .init(x: .zero, - y: .zero, - width: 100, - height: 100) + let scrollView = try XCTUnwrap(controller.scrollView) + scrollView.frame = .init(x: .zero, y: .zero, width: 100, height: 100) controller.editorOverscroll = 0 controller.contentInsets = nil From 66035bfb69a8eacc8415373e5f8cc9d700354e5e Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 17 Mar 2025 16:14:52 -0500 Subject: [PATCH 13/37] Addded Bezel Notification to editor and firing a bezel notification when looping find results or submitting when there is no result. Commented emphasize logic back in. --- .../CodeEditSourceEditorExampleApp.swift | 5 - .../Views/ContentView.swift | 4 +- .../CodeEditUI/BezelNotification.swift | 184 ++++++++++++++++++ .../CodeEditUI/EffectView.swift | 72 +++++++ .../Find/FindViewController.swift | 145 ++++++++------ 5 files changed, 348 insertions(+), 62 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift create mode 100644 Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift index 5b4ad8315..48aac83d5 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/CodeEditSourceEditorExampleApp.swift @@ -12,11 +12,6 @@ struct CodeEditSourceEditorExampleApp: App { var body: some Scene { DocumentGroup(newDocument: CodeEditSourceEditorExampleDocument()) { file in ContentView(document: file.$document, fileURL: file.fileURL) - .toolbar { - Button("Toolbar Item") { - print("Toolbar Item Pressed") - } - } } .windowToolbarStyle(.unifiedCompact) } diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index aa443ac7a..98ecc38c8 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -44,10 +44,10 @@ struct ContentView: View { cursorPositions: $cursorPositions, useThemeBackground: true, highlightProviders: [treeSitterClient], - contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 0, right: 0), + contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), useSystemCursor: useSystemCursor ) - .safeAreaInset(edge: .bottom, spacing: 0) { + .overlay(alignment: .bottom) { HStack { Toggle("Wrap Lines", isOn: $wrapLines) .toggleStyle(.button) diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift b/Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift new file mode 100644 index 000000000..cf71c2fbd --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift @@ -0,0 +1,184 @@ +// +// BezelNotification.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/17/25. +// + +import AppKit +import SwiftUI + +/// A utility class for showing temporary bezel notifications with SF Symbols +final class BezelNotification { + private static var shared = BezelNotification() + private var window: NSWindow? + private var hostingView: NSHostingView? + private var frameObserver: NSObjectProtocol? + private var targetView: NSView? + private var hideTimer: DispatchWorkItem? + + private init() {} + + deinit { + if let observer = frameObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Shows a bezel notification with the given SF Symbol name + /// - Parameters: + /// - symbolName: The name of the SF Symbol to display + /// - over: The view to center the bezel over + /// - duration: How long to show the bezel for (defaults to 0.75 seconds) + static func show(symbolName: String, over view: NSView, duration: TimeInterval = 0.75) { + shared.showBezel(symbolName: symbolName, over: view, duration: duration) + } + + private func showBezel(symbolName: String, over view: NSView, duration: TimeInterval) { + // Cancel any existing hide timer + hideTimer?.cancel() + hideTimer = nil + + // Close existing window if any + cleanup() + + self.targetView = view + + // Create the window and view + let bezelContent = BezelView(symbolName: symbolName) + let hostingView = NSHostingView(rootView: bezelContent) + self.hostingView = hostingView + + let window = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel, .hudWindow], + backing: .buffered, + defer: true + ) + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false + window.level = .floating + window.contentView = hostingView + window.isMovable = false + window.isReleasedWhenClosed = false + + // Make it a child window that moves with the parent + if let parentWindow = view.window { + parentWindow.addChildWindow(window, ordered: .above) + } + + self.window = window + + // Size and position the window + let size = NSSize(width: 110, height: 110) + hostingView.frame.size = size + + // Initial position + updateBezelPosition() + + // Observe frame changes + frameObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: view, + queue: .main + ) { [weak self] _ in + self?.updateBezelPosition() + } + + // Show immediately without fade + window.alphaValue = 1 + window.orderFront(nil) + + // Schedule hide + let timer = DispatchWorkItem { [weak self] in + self?.dismiss() + } + self.hideTimer = timer + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: timer) + } + + private func updateBezelPosition() { + guard let window = window, + let view = targetView else { return } + + let size = NSSize(width: 110, height: 110) + + // Position relative to the view's content area + let visibleRect: NSRect + if let scrollView = view.enclosingScrollView { + // Get the visible rect in the scroll view's coordinate space + visibleRect = scrollView.contentView.visibleRect + } else { + visibleRect = view.bounds + } + + // Convert visible rect to window coordinates + let viewFrameInWindow = view.enclosingScrollView?.contentView.convert(visibleRect, to: nil) + ?? view.convert(visibleRect, to: nil) + guard let screenFrame = view.window?.convertToScreen(viewFrameInWindow) else { return } + + // Calculate center position relative to the visible content area + let xPos = screenFrame.midX - (size.width / 2) + let yPos = screenFrame.midY - (size.height / 2) + + // Update frame + let bezelFrame = NSRect(origin: NSPoint(x: xPos, y: yPos), size: size) + window.setFrame(bezelFrame, display: true) + } + + private func cleanup() { + // Cancel any existing hide timer + hideTimer?.cancel() + hideTimer = nil + + // Remove frame observer + if let observer = frameObserver { + NotificationCenter.default.removeObserver(observer) + frameObserver = nil + } + + // Remove child window relationship + if let window = window, let parentWindow = window.parent { + parentWindow.removeChildWindow(window) + } + + // Close and clean up window + window?.orderOut(nil) // Ensure window is removed from screen + window?.close() + window = nil + + // Clean up hosting view + hostingView?.removeFromSuperview() + hostingView = nil + + // Clear target view reference + targetView = nil + } + + private func dismiss() { + guard let window = window else { return } + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.15 + window.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + self?.cleanup() + }) + } +} + +/// The SwiftUI view for the bezel content +private struct BezelView: View { + let symbolName: String + + var body: some View { + Image(systemName: symbolName) + .imageScale(.large) + .font(.system(size: 56, weight: .thin)) + .foregroundStyle(.secondary) + .frame(width: 110, height: 110) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerSize: CGSize(width: 18.0, height: 18.0))) + } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift b/Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift new file mode 100644 index 000000000..f9a1e6eb0 --- /dev/null +++ b/Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift @@ -0,0 +1,72 @@ +// +// EffectView.swift +// CodeEditModules/CodeEditUI +// +// Created by Rehatbir Singh on 15/03/2022. +// + +import SwiftUI + +/// A SwiftUI Wrapper for `NSVisualEffectView` +/// +/// ## Usage +/// ```swift +/// EffectView(material: .headerView, blendingMode: .withinWindow) +/// ``` +struct EffectView: NSViewRepresentable { + private let material: NSVisualEffectView.Material + private let blendingMode: NSVisualEffectView.BlendingMode + private let emphasized: Bool + + /// Initializes the + /// [`NSVisualEffectView`](https://developer.apple.com/documentation/appkit/nsvisualeffectview) + /// with a + /// [`Material`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/material) and + /// [`BlendingMode`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/blendingmode) + /// + /// By setting the + /// [`emphasized`](https://developer.apple.com/documentation/appkit/nsvisualeffectview/1644721-isemphasized) + /// flag, the emphasized state of the material will be used if available. + /// + /// - Parameters: + /// - material: The material to use. Defaults to `.headerView`. + /// - blendingMode: The blending mode to use. Defaults to `.withinWindow`. + /// - emphasized:A Boolean value indicating whether to emphasize the look of the material. Defaults to `false`. + init( + _ material: NSVisualEffectView.Material = .headerView, + blendingMode: NSVisualEffectView.BlendingMode = .withinWindow, + emphasized: Bool = false + ) { + self.material = material + self.blendingMode = blendingMode + self.emphasized = emphasized + } + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.isEmphasized = emphasized + view.state = .followsWindowActiveState + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } + + /// Returns the system selection style as an ``EffectView`` if the `condition` is met. + /// Otherwise it returns `Color.clear` + /// + /// - Parameter condition: The condition of when to apply the background. Defaults to `true`. + /// - Returns: A View + @ViewBuilder + static func selectionBackground(_ condition: Bool = true) -> some View { + if condition { + EffectView(.selection, blendingMode: .withinWindow, emphasized: true) + } else { + Color.clear + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 1c13936a7..6e717b75e 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -161,29 +161,46 @@ extension FindViewController { extension FindViewController: FindPanelDelegate { func findPanelOnSubmit() { + let previousIndex = target?.emphasizeAPI?.emphasizedRangeIndex ?? -1 target?.emphasizeAPI?.highlightNext() -// if let textViewController = target as? TextViewController, -// let emphasizeAPI = target?.emphasizeAPI, -// !emphasizeAPI.emphasizedRanges.isEmpty { -// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 -// let range = emphasizeAPI.emphasizedRanges[activeIndex].range -// textViewController.textView.scrollToRange(range) -// textViewController.setCursorPositions([CursorPosition(range: range)]) -// } + if let textViewController = target as? TextViewController, + let emphasizeAPI = target?.emphasizeAPI { + if emphasizeAPI.emphasizedRanges.isEmpty { + // Show "no matches" bezel notification and play beep + NSSound.beep() + BezelNotification.show( + symbolName: "arrow.down.to.line", + over: textViewController.textView + ) + } else { + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + let range = emphasizeAPI.emphasizedRanges[activeIndex].range + textViewController.textView.scrollToRange(range) + textViewController.setCursorPositions([CursorPosition(range: range)]) + + // Show bezel notification if we cycled from last to first match + if previousIndex == emphasizeAPI.emphasizedRanges.count - 1 && activeIndex == 0 { + BezelNotification.show( + symbolName: "arrow.triangle.capsulepath", + over: textViewController.textView + ) + } + } + } } func findPanelOnCancel() { // Return focus to the editor and restore cursor if let textViewController = target as? TextViewController { // Get the current highlight range before doing anything else -// var rangeToSelect: NSRange? -// if let emphasizeAPI = target?.emphasizeAPI { -// if !emphasizeAPI.emphasizedRanges.isEmpty { -// // Get the active highlight range -// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 -// rangeToSelect = emphasizeAPI.emphasizedRanges[activeIndex].range -// } -// } + var rangeToSelect: NSRange? + if let emphasizeAPI = target?.emphasizeAPI { + if !emphasizeAPI.emphasizedRanges.isEmpty { + // Get the active highlight range + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + rangeToSelect = emphasizeAPI.emphasizedRanges[activeIndex].range + } + } // Now hide the panel if isShowingFindPanel { @@ -197,23 +214,23 @@ extension FindViewController: FindPanelDelegate { self.view.window?.makeFirstResponder(textViewController.textView) // If we had an active highlight, select it -// if let rangeToSelect = rangeToSelect { -// // Set the selection first -// textViewController.textView.selectionManager.setSelectedRanges([rangeToSelect]) -// textViewController.setCursorPositions([CursorPosition(range: rangeToSelect)]) -// textViewController.textView.scrollToRange(rangeToSelect) -// -// // Then clear highlights after a short delay to ensure selection is set -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { -// self.target?.emphasizeAPI?.removeEmphasizeLayers() -// textViewController.textView.needsDisplay = true -// } -// } else if let currentPosition = textViewController.cursorPositions.first { -// // Otherwise ensure cursor is visible at last position -// textViewController.textView.scrollToRange(currentPosition.range) -// textViewController.textView.selectionManager.setSelectedRanges([currentPosition.range]) -// self.target?.emphasizeAPI?.removeEmphasizeLayers() -// } + if let rangeToSelect = rangeToSelect { + // Set the selection first + textViewController.textView.selectionManager.setSelectedRanges([rangeToSelect]) + textViewController.setCursorPositions([CursorPosition(range: rangeToSelect)]) + textViewController.textView.scrollToRange(rangeToSelect) + + // Then clear highlights after a short delay to ensure selection is set + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.target?.emphasizeAPI?.removeEmphasizeLayers() + textViewController.textView.needsDisplay = true + } + } else if let currentPosition = textViewController.cursorPositions.first { + // Otherwise ensure cursor is visible at last position + textViewController.textView.scrollToRange(currentPosition.range) + textViewController.textView.selectionManager.setSelectedRanges([currentPosition.range]) + self.target?.emphasizeAPI?.removeEmphasizeLayers() + } } } } @@ -231,27 +248,45 @@ extension FindViewController: FindPanelDelegate { } func findPanelPrevButtonClicked() { + let previousIndex = target?.emphasizeAPI?.emphasizedRangeIndex ?? -1 target?.emphasizeAPI?.highlightPrevious() -// if let textViewController = target as? TextViewController, -// let emphasizeAPI = target?.emphasizeAPI, -// !emphasizeAPI.emphasizedRanges.isEmpty { -// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 -// let range = emphasizeAPI.emphasizedRanges[activeIndex].range -// textViewController.textView.scrollToRange(range) -// textViewController.setCursorPositions([CursorPosition(range: range)]) -// } + if let textViewController = target as? TextViewController, + let emphasizeAPI = target?.emphasizeAPI, + !emphasizeAPI.emphasizedRanges.isEmpty { + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + let range = emphasizeAPI.emphasizedRanges[activeIndex].range + textViewController.textView.scrollToRange(range) + textViewController.setCursorPositions([CursorPosition(range: range)]) + + // Show bezel notification if we cycled from first to last match + if previousIndex == 0 && activeIndex == emphasizeAPI.emphasizedRanges.count - 1 { + BezelNotification.show( + symbolName: "arrow.trianglehead.bottomleft.capsulepath.clockwise", + over: textViewController.textView + ) + } + } } func findPanelNextButtonClicked() { + let previousIndex = target?.emphasizeAPI?.emphasizedRangeIndex ?? -1 target?.emphasizeAPI?.highlightNext() -// if let textViewController = target as? TextViewController, -// let emphasizeAPI = target?.emphasizeAPI, -// !emphasizeAPI.emphasizedRanges.isEmpty { -// let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 -// let range = emphasizeAPI.emphasizedRanges[activeIndex].range -// textViewController.textView.scrollToRange(range) -// textViewController.setCursorPositions([CursorPosition(range: range)]) -// } + if let textViewController = target as? TextViewController, + let emphasizeAPI = target?.emphasizeAPI, + !emphasizeAPI.emphasizedRanges.isEmpty { + let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 + let range = emphasizeAPI.emphasizedRanges[activeIndex].range + textViewController.textView.scrollToRange(range) + textViewController.setCursorPositions([CursorPosition(range: range)]) + + // Show bezel notification if we cycled from last to first match + if previousIndex == emphasizeAPI.emphasizedRanges.count - 1 && activeIndex == 0 { + BezelNotification.show( + symbolName: "arrow.triangle.capsulepath", + over: textViewController.textView + ) + } + } } func searchFile(query: String) { @@ -290,16 +325,16 @@ extension FindViewController: FindPanelDelegate { findPanel.searchDelegate?.findPanelUpdateMatchCount(searchResults.count) // If we have an active highlight and the same number of matches, try to preserve the active index -// let currentActiveIndex = target.emphasizeAPI?.emphasizedRangeIndex ?? 0 -// let activeIndex = (target.emphasizeAPI?.emphasizedRanges.count == searchResults.count) ? -// currentActiveIndex : 0 + let currentActiveIndex = target.emphasizeAPI?.emphasizedRangeIndex ?? 0 + let activeIndex = (target.emphasizeAPI?.emphasizedRanges.count == searchResults.count) ? + currentActiveIndex : 0 + + emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) -// emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) - // Only set cursor position if we're actively searching (not when clearing) if !query.isEmpty { // Always select the active highlight -// target.setCursorPositions([CursorPosition(range: searchResults[activeIndex])]) + target.setCursorPositions([CursorPosition(range: searchResults[activeIndex])]) } } From 66d850861e5a400ba8dcd6136afa9e959d72ae8d Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 20 Mar 2025 02:56:26 -0500 Subject: [PATCH 14/37] Enabled anti-aliasing and font smoothing for line numbers. Changed line number text color. --- .../Views/ContentView.swift | 2 +- .../Controller/TextViewController+LoadView.swift | 12 +++++++++++- .../Controller/TextViewController.swift | 2 ++ .../CodeEditSourceEditor/Gutter/GutterView.swift | 13 +++---------- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 98ecc38c8..52001f502 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -19,7 +19,7 @@ struct ContentView: View { @State private var language: CodeLanguage = .default @State private var theme: EditorTheme = .light - @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true @State private var cursorPositions: [CursorPosition] = [] @AppStorage("systemCursor") private var useSystemCursor: Bool = false diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 2dca6755f..189a6acf3 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -18,7 +18,8 @@ extension TextViewController { gutterView = GutterView( font: font.rulerFont, - textColor: .secondaryLabelColor, + textColor: theme.text.color.withAlphaComponent(0.35), + selectedTextColor: theme.text.color, textView: textView, delegate: self ) @@ -105,6 +106,15 @@ extension TextViewController { if self.systemAppearance != newValue.name { self.systemAppearance = newValue.name + + // Reset content insets and gutter position when appearance changes + if let contentInsets = self.contentInsets { + self.scrollView.contentInsets = contentInsets + if let searchController = self.searchController, searchController.isShowingFindPanel { + self.scrollView.contentInsets.top += FindPanel.height + } + self.gutterView.frame.origin.y = -self.scrollView.contentInsets.top + } } } .store(in: &cancellables) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 0751d0c1e..54db0de29 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -71,6 +71,8 @@ public class TextViewController: NSViewController { ) textView.selectionManager.selectedLineBackgroundColor = theme.selection highlighter?.invalidate() + gutterView.textColor = theme.text.color.withAlphaComponent(0.35) + gutterView.selectedLineTextColor = theme.text.color } } diff --git a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift index 31568d4a1..8832eb337 100644 --- a/Sources/CodeEditSourceEditor/Gutter/GutterView.swift +++ b/Sources/CodeEditSourceEditor/Gutter/GutterView.swift @@ -60,7 +60,7 @@ public class GutterView: NSView { var highlightSelectedLines: Bool = true @Invalidating(.display) - var selectedLineTextColor: NSColor? = .textColor + var selectedLineTextColor: NSColor? = .labelColor @Invalidating(.display) var selectedLineColor: NSColor = NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) @@ -81,11 +81,13 @@ public class GutterView: NSView { public init( font: NSFont, textColor: NSColor, + selectedTextColor: NSColor?, textView: TextView, delegate: GutterViewDelegate? = nil ) { self.font = font self.textColor = textColor + self.selectedLineTextColor = selectedTextColor ?? .secondaryLabelColor self.textView = textView self.delegate = delegate @@ -201,15 +203,6 @@ public class GutterView: NSView { context.saveGState() - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - context.setAllowsFontSmoothing(false) - context.setAllowsFontSubpixelPositioning(true) - context.setShouldSubpixelPositionFonts(true) - context.setAllowsFontSubpixelQuantization(true) - context.setShouldSubpixelQuantizeFonts(true) - ContextSetHiddenSmoothingStyle(context, 16) - context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) for linePosition in textView.layoutManager.visibleLines() { if selectionRangeMap.intersects(integersIn: linePosition.range) { From deebe233a40e5e03986bb353f9c015a9d21d67d2 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 20 Mar 2025 19:37:08 +0100 Subject: [PATCH 15/37] Highlight the closes element to the users cursor - If the users cursor is in view, the highlighted item will be next to the cursor - If the users cursor is out of view, we'll highlight the closed item to the visibe area --- .../Find/FindViewController.swift | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 6e717b75e..3f994e108 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -177,7 +177,7 @@ extension FindViewController: FindPanelDelegate { let range = emphasizeAPI.emphasizedRanges[activeIndex].range textViewController.textView.scrollToRange(range) textViewController.setCursorPositions([CursorPosition(range: range)]) - + // Show bezel notification if we cycled from last to first match if previousIndex == emphasizeAPI.emphasizedRanges.count - 1 && activeIndex == 0 { BezelNotification.show( @@ -324,10 +324,8 @@ extension FindViewController: FindPanelDelegate { let searchResults = matches.map { $0.range } findPanel.searchDelegate?.findPanelUpdateMatchCount(searchResults.count) - // If we have an active highlight and the same number of matches, try to preserve the active index - let currentActiveIndex = target.emphasizeAPI?.emphasizedRangeIndex ?? 0 - let activeIndex = (target.emphasizeAPI?.emphasizedRanges.count == searchResults.count) ? - currentActiveIndex : 0 + // Get the nearest match to either the cursor or visible area + let activeIndex = getNearestHighlightIndex(matchRanges: searchResults) ?? 0 emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) @@ -338,49 +336,46 @@ extension FindViewController: FindPanelDelegate { } } - private func getNearestHighlightIndex(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 + private func getNearestHighlightIndex(matchRanges: borrowing [NSRange]) -> Int? { + guard !matchRanges.isEmpty, + let textViewController = target as? TextViewController, + let textView = textViewController.textView, + let visibleRange = textView.visibleTextRange else { return nil } + + // Determine target position based on cursor visibility + let targetPosition: Int + if let cursorPosition = textViewController.cursorPositions.first?.range.location, + visibleRange.contains(cursorPosition) { + targetPosition = cursorPosition + } else { + targetPosition = visibleRange.location + } - var left = 0 - var right = matchRanges.count - 1 - var bestIndex = -1 - var bestDiff = Int.max // Stores the closest difference + // Binary search for the nearest match + var left = 0, right = matchRanges.count - 1 + var bestIndex: Int? = nil + var bestDiff = Int.max while left <= right { let mid = left + (right - left) / 2 let midStart = matchRanges[mid].location - let diff = abs(midStart - start) + let diff = abs(midStart - targetPosition) - // 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 { + if midStart < targetPosition { left = mid + 1 } else { right = mid - 1 } } - return bestIndex >= 0 ? bestIndex : nil + return bestIndex } - // Only re-serach the part of the file that changed upwards - private func reSearch() { } - // Returns true if string contains uppercase letter // used for: ignores letter case if the search query is all lowercase private func smartCase(str: String) -> Bool { From fb2c6706d27bf3f3fadcae64bcf52d2443c80974 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Thu, 20 Mar 2025 20:31:44 +0100 Subject: [PATCH 16/37] Add documentation --- Sources/CodeEditSourceEditor/Find/FindViewController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 3f994e108..91800724d 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -290,7 +290,6 @@ extension FindViewController: FindPanelDelegate { } func searchFile(query: String) { - // Don't search if target or emphasizeAPI isn't ready guard let target = target, let emphasizeAPI = target.emphasizeAPI else { findPanel.searchDelegate?.findPanelUpdateMatchCount(0) @@ -336,6 +335,10 @@ extension FindViewController: FindPanelDelegate { } } + /// Finds the index of the nearest emphasised match range relative to the cursor or visible text range. + /// + /// - Parameter matchRanges: An array of `NSRange` representing the emphasised match locations. + /// - Returns: The index of the nearest match in `matchRanges`, or `nil` if no matches are found. private func getNearestHighlightIndex(matchRanges: borrowing [NSRange]) -> Int? { guard !matchRanges.isEmpty, let textViewController = target as? TextViewController, From 968a4e96e79f21a8f01cff81912a3e547d20bf01 Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Sat, 22 Mar 2025 13:03:06 +0100 Subject: [PATCH 17/37] Fix linter --- .../Controller/TextViewController+LoadView.swift | 7 ++++++- Sources/CodeEditSourceEditor/Find/FindViewController.swift | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 189a6acf3..e9f342edf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -122,7 +122,11 @@ extension TextViewController { if let localEventMonitor = self.localEvenMonitor { NSEvent.removeMonitor(localEventMonitor) } - self.localEvenMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in + setUpKeyBindings(eventMonitor: &self.localEvenMonitor) + } + + func setUpKeyBindings(eventMonitor: inout Any?) { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in guard self?.view.window?.firstResponder == self?.textView else { return event } let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) @@ -142,6 +146,7 @@ extension TextViewController { } } } + func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 91800724d..2007cb2d0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -289,7 +289,7 @@ extension FindViewController: FindPanelDelegate { } } - func searchFile(query: String) { + func searchFile(query: String, respectCursorPosition: Bool = true) { guard let target = target, let emphasizeAPI = target.emphasizeAPI else { findPanel.searchDelegate?.findPanelUpdateMatchCount(0) @@ -324,7 +324,7 @@ extension FindViewController: FindPanelDelegate { findPanel.searchDelegate?.findPanelUpdateMatchCount(searchResults.count) // Get the nearest match to either the cursor or visible area - let activeIndex = getNearestHighlightIndex(matchRanges: searchResults) ?? 0 + let activeIndex = respectCursorPosition ? getNearestHighlightIndex(matchRanges: searchResults) ?? 0 : 0 emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) @@ -356,7 +356,7 @@ extension FindViewController: FindPanelDelegate { // Binary search for the nearest match var left = 0, right = matchRanges.count - 1 - var bestIndex: Int? = nil + var bestIndex: Int? var bestDiff = Int.max while left <= right { From cd0a8b9ebb0d430780f465bfdbdbe1f83da079a3 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 27 Mar 2025 21:47:56 -0500 Subject: [PATCH 18/37] Renamed EmphasisAPI to EmphasisManager. Separated concerns by moving match cycle logic from EmphasisManager to FindViewController. Using EmphasisManager in bracket pair matching instead of custom implementation reducing duplicated code. Implemented flash find matches when clicking the next and previous buttons when the editor is in focus. `bracketPairHighlight` becomes `bracketPairEmphasis`. Fixed various find issues and cleaned up implementation. --- .../CodeEditSourceEditor.swift | 26 +- .../TextViewController+EmphasizeBracket.swift | 156 +++++++ .../TextViewController+FindPanelTarget.swift | 4 +- .../TextViewController+HighlightBracket.swift | 221 --------- .../TextViewController+LoadView.swift | 6 +- .../TextViewController+TextFormation.swift | 2 +- .../Controller/TextViewController.swift | 8 +- ...hlight.swift => BracketPairEmphasis.swift} | 20 +- .../TextView+/TextView+createReadBlock.swift | 2 +- .../Find/FindPanelTarget.swift | 5 +- .../Find/FindViewController.swift | 432 +++++++++++------- .../Find/PanelView/FindPanel.swift | 38 +- .../Find/PanelView/FindPanelView.swift | 17 +- .../Find/PanelView/FindPanelViewModel.swift | 47 +- Tests/CodeEditSourceEditorTests/Mock.swift | 3 +- .../TextViewControllerTests.swift | 10 +- 16 files changed, 520 insertions(+), 477 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift delete mode 100644 Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift rename Sources/CodeEditSourceEditor/Enums/{BracketPairHighlight.swift => BracketPairEmphasis.swift} (57%) diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index b2f0dd8bc..85652765d 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -40,7 +40,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// value is true, and `isEditable` is false, the editor is selectable but not editable. /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a /// character's width between characters, etc. Defaults to `1.0` - /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. + /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. /// See `BracketPairHighlight` for more information. Defaults to `nil` /// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`. /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager @@ -62,7 +62,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, - bracketPairHighlight: BracketPairHighlight? = nil, + bracketPairEmphasis: BracketPairEmphasis? = .flash, useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -83,7 +83,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing - self.bracketPairHighlight = bracketPairHighlight + self.bracketPairEmphasis = bracketPairEmphasis if #available(macOS 14, *) { self.useSystemCursor = useSystemCursor } else { @@ -116,8 +116,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// value is true, and `isEditable` is false, the editor is selectable but not editable. /// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a /// character's width between characters, etc. Defaults to `1.0` - /// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs. - /// See `BracketPairHighlight` for more information. Defaults to `nil` + /// - bracketPairEmphasis: The type of highlight to use to highlight bracket pairs. + /// See `BracketPairEmphasis` for more information. Defaults to `nil` /// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager /// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information. public init( @@ -137,7 +137,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, - bracketPairHighlight: BracketPairHighlight? = nil, + bracketPairEmphasis: BracketPairEmphasis? = .flash, useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] @@ -158,7 +158,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing - self.bracketPairHighlight = bracketPairHighlight + self.bracketPairEmphasis = bracketPairEmphasis if #available(macOS 14, *) { self.useSystemCursor = useSystemCursor } else { @@ -184,7 +184,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var isEditable: Bool private var isSelectable: Bool private var letterSpacing: Double - private var bracketPairHighlight: BracketPairHighlight? + private var bracketPairEmphasis: BracketPairEmphasis? private var useSystemCursor: Bool private var undoManager: CEUndoManager? package var coordinators: [any TextViewCoordinator] @@ -210,7 +210,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { isSelectable: isSelectable, letterSpacing: letterSpacing, useSystemCursor: useSystemCursor, - bracketPairHighlight: bracketPairHighlight, + bracketPairEmphasis: bracketPairEmphasis, undoManager: undoManager, coordinators: coordinators ) @@ -309,7 +309,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.setHighlightProviders(highlightProviders) } - controller.bracketPairHighlight = bracketPairHighlight + if controller.bracketPairEmphasis != bracketPairEmphasis { + controller.bracketPairEmphasis = bracketPairEmphasis + } } /// Checks if the controller needs updating. @@ -329,7 +331,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && - controller.bracketPairHighlight == bracketPairHighlight && + controller.bracketPairEmphasis == bracketPairEmphasis && controller.useSystemCursor == useSystemCursor && areHighlightProvidersEqual(controller: controller) } @@ -359,7 +361,7 @@ public struct CodeEditTextView: View { isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, - bracketPairHighlight: BracketPairHighlight? = nil, + bracketPairEmphasis: BracketPairEmphasis? = nil, undoManager: CEUndoManager? = nil, coordinators: [any TextViewCoordinator] = [] ) { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift new file mode 100644 index 000000000..6ed9c58cf --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift @@ -0,0 +1,156 @@ +// +// TextViewController+EmphasizeBracket.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/26/23. +// + +import AppKit +import CodeEditTextView + +extension TextViewController { + /// Emphasizes bracket pairs using the current selection. + internal func emphasizeSelectionPairs() { + guard bracketPairEmphasis != nil else { return } + textView.emphasisManager?.removeEmphases(for: "bracketPairs") + for range in textView.selectionManager.textSelections.map({ $0.range }) { + if range.isEmpty, + range.location > 0, // Range is not the beginning of the document + let precedingCharacter = textView.textStorage.substring( + from: NSRange(location: range.location - 1, length: 1) // The preceding character exists + ) { + for pair in BracketPairs.emphasisValues { + if precedingCharacter == pair.0 { + // Walk forwards + if let characterIndex = findClosingPair( + pair.0, + pair.1, + from: range.location, + limit: min(NSMaxRange(textView.visibleTextRange ?? .zero) + 4096, + NSMaxRange(textView.documentRange)), + reverse: false + ) { + emphasizeCharacter(characterIndex) + if bracketPairEmphasis?.emphasizesSourceBracket ?? false { + emphasizeCharacter(range.location - 1) + } + } + } else if precedingCharacter == pair.1 && range.location - 1 > 0 { + // Walk backwards + if let characterIndex = findClosingPair( + pair.1, + pair.0, + from: range.location - 1, + limit: max((textView.visibleTextRange?.location ?? 0) - 4096, + textView.documentRange.location), + reverse: true + ) { + emphasizeCharacter(characterIndex) + if bracketPairEmphasis?.emphasizesSourceBracket ?? false { + emphasizeCharacter(range.location - 1) + } + } + } + } + } + } + } + + /// # Dev Note + /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each + /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed + /// and this lookup would be much faster. + + /// Finds a closing character given a pair of characters, ignores pairs inside the given pair. + /// + /// ```pseudocode + /// { -- Start + /// { + /// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. + /// } -- Found + /// ``` + /// + /// - Parameters: + /// - open: The opening pair to look for. + /// - close: The closing pair to look for. + /// - from: The index to start from. This should not include the start character. Eg given `"{ }"` looking forward + /// the index should be `1` + /// - limit: A limiting index to stop at. When `reverse` is `true`, this is the minimum index. When `false` this + /// is the maximum index. + /// - reverse: Set to `true` to walk backwards from `from`. + /// - Returns: The index of the found closing pair, if any. + internal func findClosingPair(_ close: String, _ open: String, from: Int, limit: Int, reverse: Bool) -> Int? { + // Walk the text, counting each close. When we find an open that makes closeCount < 0, return that index. + var options: NSString.EnumerationOptions = .byCaretPositions + if reverse { + options = options.union(.reverse) + } + var closeCount = 0 + var index: Int? + textView.textStorage.mutableString.enumerateSubstrings( + in: reverse ? + NSRange(location: limit, length: from - limit) : + NSRange(location: from, length: limit - from), + options: options, + using: { substring, range, _, stop in + if substring == close { + closeCount += 1 + } else if substring == open { + closeCount -= 1 + } + + if closeCount < 0 { + index = range.location + stop.pointee = true + } + } + ) + return index + } + + /// Adds a temporary emphasis effect to the character at the given location. + /// - Parameters: + /// - location: The location of the character to emphasize + /// - scrollToRange: Set to true to scroll to the given range when emphasizing. Defaults to `false`. + private func emphasizeCharacter(_ location: Int, scrollToRange: Bool = false) { + guard let bracketPairEmphasis = bracketPairEmphasis, + let rectToEmphasize = textView.layoutManager.rectForOffset(location) else { + return + } + + let range = NSRange(location: location, length: 1) + + switch bracketPairEmphasis { + case .flash: + textView.emphasisManager?.addEmphasis( + Emphasis( + range: range, + style: .standard, + flash: true, + inactive: false + ), + for: "bracketPairs" + ) + case .bordered(let borderColor): + textView.emphasisManager?.addEmphasis( + Emphasis( + range: range, + style: .outline(color: borderColor), + flash: false, + inactive: false + ), + for: "bracketPairs" + ) + case .underline(let underlineColor): + textView.emphasisManager?.addEmphasis( + Emphasis( + range: range, + style: .underline(color: underlineColor), + flash: false, + inactive: false + ), + for: "bracketPairs" + ) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 3230ecd06..36e4b45f7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -19,7 +19,7 @@ extension TextViewController: FindPanelTarget { gutterView.frame.origin.y = -scrollView.contentInsets.top } - var emphasizeAPI: EmphasizeAPI? { - textView?.emphasizeAPI + var emphasisManager: EmphasisManager? { + textView?.emphasisManager } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift deleted file mode 100644 index a8297eb6a..000000000 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+HighlightBracket.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// TextViewController+HighlightRange.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/26/23. -// - -import AppKit - -extension TextViewController { - /// Highlights bracket pairs using the current selection. - internal func highlightSelectionPairs() { - guard bracketPairHighlight != nil else { return } - removeHighlightLayers() - for range in textView.selectionManager.textSelections.map({ $0.range }) { - if range.isEmpty, - range.location > 0, // Range is not the beginning of the document - let precedingCharacter = textView.textStorage.substring( - from: NSRange(location: range.location - 1, length: 1) // The preceding character exists - ) { - for pair in BracketPairs.highlightValues { - if precedingCharacter == pair.0 { - // Walk forwards - if let characterIndex = findClosingPair( - pair.0, - pair.1, - from: range.location, - limit: min(NSMaxRange(textView.visibleTextRange ?? .zero) + 4096, - NSMaxRange(textView.documentRange)), - reverse: false - ) { - highlightCharacter(characterIndex) - if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightCharacter(range.location - 1) - } - } - } else if precedingCharacter == pair.1 && range.location - 1 > 0 { - // Walk backwards - if let characterIndex = findClosingPair( - pair.1, - pair.0, - from: range.location - 1, - limit: max((textView.visibleTextRange?.location ?? 0) - 4096, - textView.documentRange.location), - reverse: true - ) { - highlightCharacter(characterIndex) - if bracketPairHighlight?.highlightsSourceBracket ?? false { - highlightCharacter(range.location - 1) - } - } - } - } - } - } - } - - /// # Dev Note - /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each - /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed - /// and this lookup would be much faster. - - /// Finds a closing character given a pair of characters, ignores pairs inside the given pair. - /// - /// ```pseudocode - /// { -- Start - /// { - /// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. - /// } -- Found - /// ``` - /// - /// - Parameters: - /// - open: The opening pair to look for. - /// - close: The closing pair to look for. - /// - from: The index to start from. This should not include the start character. Eg given `"{ }"` looking forward - /// the index should be `1` - /// - limit: A limiting index to stop at. When `reverse` is `true`, this is the minimum index. When `false` this - /// is the maximum index. - /// - reverse: Set to `true` to walk backwards from `from`. - /// - Returns: The index of the found closing pair, if any. - internal func findClosingPair(_ close: String, _ open: String, from: Int, limit: Int, reverse: Bool) -> Int? { - // Walk the text, counting each close. When we find an open that makes closeCount < 0, return that index. - var options: NSString.EnumerationOptions = .byCaretPositions - if reverse { - options = options.union(.reverse) - } - var closeCount = 0 - var index: Int? - textView.textStorage.mutableString.enumerateSubstrings( - in: reverse ? - NSRange(location: limit, length: from - limit) : - NSRange(location: from, length: limit - from), - options: options, - using: { substring, range, _, stop in - if substring == close { - closeCount += 1 - } else if substring == open { - closeCount -= 1 - } - - if closeCount < 0 { - index = range.location - stop.pointee = true - } - } - ) - return index - } - - /// Adds a temporary highlight effect to the character at the given location. - /// - Parameters: - /// - location: The location of the character to highlight - /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. - private func highlightCharacter(_ location: Int, scrollToRange: Bool = false) { - guard let bracketPairHighlight = bracketPairHighlight, - var rectToHighlight = textView.layoutManager.rectForOffset(location) else { - return - } - let layer = CAShapeLayer() - - switch bracketPairHighlight { - case .flash: - rectToHighlight.size.width += 4 - rectToHighlight.origin.x -= 2 - - layer.cornerRadius = 3.0 - layer.backgroundColor = NSColor(hex: 0xFEFA80, alpha: 1.0).cgColor - layer.shadowColor = .black - layer.shadowOpacity = 0.3 - layer.shadowOffset = CGSize(width: 0, height: 1) - layer.shadowRadius = 3.0 - layer.opacity = 0.0 - case .bordered(let borderColor): - layer.borderColor = borderColor.cgColor - layer.cornerRadius = 2.5 - layer.borderWidth = 0.5 - layer.opacity = 1.0 - case .underline(let underlineColor): - layer.lineWidth = 1.0 - layer.lineCap = .round - layer.strokeColor = underlineColor.cgColor - layer.opacity = 1.0 - } - - switch bracketPairHighlight { - case .flash, .bordered: - layer.frame = rectToHighlight - case .underline: - let path = CGMutablePath() - let pathY = rectToHighlight.maxY - (rectToHighlight.height * (lineHeightMultiple - 1))/4 - path.move(to: CGPoint(x: rectToHighlight.minX, y: pathY)) - path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: pathY)) - layer.path = path - } - - // Insert above selection but below text - textView.layer?.insertSublayer(layer, at: 1) - - if bracketPairHighlight == .flash { - addFlashAnimation(to: layer, rectToHighlight: rectToHighlight) - } - - highlightLayers.append(layer) - - // Scroll the last rect into view, makes a small assumption that the last rect is the lowest visually. - if scrollToRange { - textView.scrollToVisible(rectToHighlight) - } - } - - /// Adds a flash animation to the given layer. - /// - Parameters: - /// - layer: The layer to add the animation to. - /// - rectToHighlight: The layer's bounding rect to animate. - private func addFlashAnimation(to layer: CALayer, rectToHighlight: CGRect) { - CATransaction.begin() - CATransaction.setCompletionBlock { [weak self] in - if let index = self?.highlightLayers.firstIndex(of: layer) { - self?.highlightLayers.remove(at: index) - } - layer.removeFromSuperlayer() - } - let duration = 0.75 - let group = CAAnimationGroup() - group.duration = duration - - let opacityAnim = CAKeyframeAnimation(keyPath: "opacity") - opacityAnim.duration = duration - opacityAnim.values = [1.0, 1.0, 0.0] - opacityAnim.keyTimes = [0.1, 0.8, 0.9] - - let positionAnim = CAKeyframeAnimation(keyPath: "position") - positionAnim.keyTimes = [0.0, 0.05, 0.1] - positionAnim.values = [ - NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y), - NSPoint(x: rectToHighlight.origin.x - 2, y: rectToHighlight.origin.y - 2), - NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y) - ] - positionAnim.duration = duration - - var betweenSize = rectToHighlight - betweenSize.size.width += 4 - betweenSize.size.height += 4 - let boundsAnim = CAKeyframeAnimation(keyPath: "bounds") - boundsAnim.keyTimes = [0.0, 0.05, 0.1] - boundsAnim.values = [rectToHighlight, betweenSize, rectToHighlight] - boundsAnim.duration = duration - - group.animations = [opacityAnim, boundsAnim] - layer.add(group, forKey: nil) - CATransaction.commit() - } - - /// Safely removes all highlight layers. - internal func removeHighlightLayers() { - highlightLayers.forEach { layer in - layer.removeFromSuperlayer() - } - highlightLayers.removeAll() - } -} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index e9f342edf..7041d0ec4 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -74,8 +74,8 @@ extension TextViewController { ) { [weak self] _ in self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) self?.gutterView.needsDisplay = true - if self?.bracketPairHighlight == .flash { - self?.removeHighlightLayers() + if self?.bracketPairEmphasis == .flash { + self?.emphasisManager?.removeEmphases(for: "bracketPairs") } } @@ -94,7 +94,7 @@ extension TextViewController { queue: .main ) { [weak self] _ in self?.updateCursorPosition() - self?.highlightSelectionPairs() + self?.emphasizeSelectionPairs() } textView.updateFrameIfNeeded() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift index 05820fae6..002e3807b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift @@ -21,7 +21,7 @@ extension TextViewController { ("'", "'") ] - static let highlightValues: [(String, String)] = [ + static let emphasisValues: [(String, String)] = [ ("{", "}"), ("[", "]"), ("(", ")") diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 54db0de29..ec856da4a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -150,9 +150,9 @@ public class TextViewController: NSViewController { } /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. - public var bracketPairHighlight: BracketPairHighlight? { + public var bracketPairEmphasis: BracketPairEmphasis? { didSet { - highlightSelectionPairs() + emphasizeSelectionPairs() } } @@ -240,7 +240,7 @@ public class TextViewController: NSViewController { isSelectable: Bool, letterSpacing: Double, useSystemCursor: Bool, - bracketPairHighlight: BracketPairHighlight?, + bracketPairEmphasis: BracketPairEmphasis?, undoManager: CEUndoManager? = nil, coordinators: [TextViewCoordinator] = [] ) { @@ -259,7 +259,7 @@ public class TextViewController: NSViewController { self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing - self.bracketPairHighlight = bracketPairHighlight + self.bracketPairEmphasis = bracketPairEmphasis self._undoManager = undoManager super.init(nibName: nil, bundle: nil) diff --git a/Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift b/Sources/CodeEditSourceEditor/Enums/BracketPairEmphasis.swift similarity index 57% rename from Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift rename to Sources/CodeEditSourceEditor/Enums/BracketPairEmphasis.swift index 3a20fedd2..7da0e9945 100644 --- a/Sources/CodeEditSourceEditor/Enums/BracketPairHighlight.swift +++ b/Sources/CodeEditSourceEditor/Enums/BracketPairEmphasis.swift @@ -1,5 +1,5 @@ // -// BracketPairHighlight.swift +// BracketPairEmphasis.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/3/23. @@ -7,20 +7,20 @@ import AppKit -/// An enum representing the type of highlight to use for bracket pairs. -public enum BracketPairHighlight: Equatable { - /// Highlight both the opening and closing character in a pair with a bounding box. +/// An enum representing the type of emphasis to use for bracket pairs. +public enum BracketPairEmphasis: Equatable { + /// Emphasize both the opening and closing character in a pair with a bounding box. /// The boxes will stay on screen until the cursor moves away from the bracket pair. case bordered(color: NSColor) - /// Flash a yellow highlight box on only the opposite character in the pair. - /// This is closely matched to Xcode's flash highlight for bracket pairs, and animates in and out over the course + /// Flash a yellow emphasis box on only the opposite character in the pair. + /// This is closely matched to Xcode's flash emphasis for bracket pairs, and animates in and out over the course /// of `0.75` seconds. case flash - /// Highlight both the opening and closing character in a pair with an underline. + /// Emphasize both the opening and closing character in a pair with an underline. /// The underline will stay on screen until the cursor moves away from the bracket pair. case underline(color: NSColor) - public static func == (lhs: BracketPairHighlight, rhs: BracketPairHighlight) -> Bool { + public static func == (lhs: BracketPairEmphasis, rhs: BracketPairEmphasis) -> Bool { switch (lhs, rhs) { case (.flash, .flash): return true @@ -33,8 +33,8 @@ public enum BracketPairHighlight: Equatable { } } - /// Returns `true` if the highlight should act on both the opening and closing bracket. - var highlightsSourceBracket: Bool { + /// Returns `true` if the emphasis should act on both the opening and closing bracket. + var emphasizesSourceBracket: Bool { switch self { case .bordered, .underline: return true diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift index fe3c06643..38153e154 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+createReadBlock.swift @@ -1,5 +1,5 @@ // -// HighlighterTextView+createReadBlock.swift +// TextView+createReadBlock.swift // CodeEditSourceEditor // // Created by Khan Winter on 5/20/23. diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index 3a01c4094..af0facadd 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -6,13 +6,10 @@ // import Foundation - -// This dependency is not ideal, maybe we could make this another protocol that the emphasize API conforms to similar -// to this one? import CodeEditTextView protocol FindPanelTarget: AnyObject { - var emphasizeAPI: EmphasizeAPI? { get } + var emphasisManager: EmphasisManager? { get } var text: String { get } var cursorPositions: [CursorPosition] { get } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 2007cb2d0..245afccd4 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -6,12 +6,16 @@ // import AppKit +import CodeEditTextView -/// Creates a container controller for displaying and hiding a search bar with a content view. +/// Creates a container controller for displaying and hiding a find panel with a content view. final class FindViewController: NSViewController { weak var target: FindPanelTarget? var childView: NSView var findPanel: FindPanel! + private var findMatches: [NSRange] = [] + private var currentFindMatchIndex: Int = 0 + private var findText: String = "" private var findPanelVerticalConstraint: NSLayoutConstraint! @@ -22,6 +26,86 @@ final class FindViewController: NSViewController { self.childView = childView 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(query: findText) + } + } + + private func performFind(query: String) { + // 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 query.isEmpty { + findPanel.findDelegate?.findPanelUpdateMatchCount(0) + findMatches = [] + currentFindMatchIndex = 0 + return + } + + let findOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: query) + + 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: "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, + select: index == currentFindMatchIndex + ) + } + + // Add all emphases + emphasisManager.addEmphases(emphases, for: "find") } required init?(coder: NSCoder) { @@ -32,9 +116,9 @@ final class FindViewController: NSViewController { super.loadView() // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to - // the search bar's bottom - // The search bar is constrained to the top of the view. - // The search bar's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. + // the find panel's bottom + // The find panel is constrained to the top of the view. + // The find panel's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. // When visible, it's set to 0. view.clipsToBounds = false @@ -48,7 +132,7 @@ final class FindViewController: NSViewController { findPanelVerticalConstraint = findPanel.topAnchor.constraint(equalTo: view.topAnchor) NSLayoutConstraint.activate([ - // Constrain search bar + // Constrain find panel findPanelVerticalConstraint, findPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor), findPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -73,7 +157,7 @@ final class FindViewController: NSViewController { /// Sets the find panel constraint to show the find panel. /// Can be animated using implicit animation. private func setFindPanelConstraintShow() { - // Update the search bar's top to be equal to the view's top. + // Update the find panel's top to be equal to the view's top. findPanelVerticalConstraint.constant = view.safeAreaInsets.top findPanelVerticalConstraint.isActive = true } @@ -81,7 +165,7 @@ final class FindViewController: NSViewController { /// Sets the find panel constraint to hide the find panel. /// Can be animated using implicit animation. private func setFindPanelConstraintHide() { - // Update the search bar's top anchor to be equal to it's negative height, hiding it above the view. + // Update the find panel's top anchor to be equal to it's negative height, hiding it above the view. // 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. @@ -91,10 +175,10 @@ final class FindViewController: NSViewController { } } -// MARK: - Toggle Search Bar +// MARK: - Toggle find panel extension FindViewController { - /// Toggle the search bar + /// Toggle the find panel func toggleFindPanel() { if isShowingFindPanel { hideFindPanel() @@ -103,9 +187,14 @@ extension FindViewController { } } - /// Show the search bar + /// Show the find panel func showFindPanel(animated: Bool = true) { - guard !isShowingFindPanel else { return } + if isShowingFindPanel { + // If panel is already showing, just focus the text field + _ = findPanel?.becomeFirstResponder() + return + } + isShowingFindPanel = true let updates: () -> Void = { [self] in @@ -122,12 +211,14 @@ extension FindViewController { } _ = findPanel?.becomeFirstResponder() + findPanel?.addEventMonitor() } - /// Hide the search bar + /// Hide the find panel func hideFindPanel(animated: Bool = true) { isShowingFindPanel = false _ = findPanel?.resignFirstResponder() + findPanel?.removeEventMonitor() let updates: () -> Void = { [self] in target?.findPanelWillHide(panelHeight: FindPanel.height) @@ -139,6 +230,11 @@ extension FindViewController { } else { updates() } + + // Set first responder back to text view + if let textViewController = target as? TextViewController { + _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) + } } /// Runs the `animatable` callback in an animation context with implicit animation enabled. @@ -157,203 +253,189 @@ extension FindViewController { } } -// MARK: - Search Bar Delegate +// MARK: - Find Panel Delegate extension FindViewController: FindPanelDelegate { func findPanelOnSubmit() { - let previousIndex = target?.emphasizeAPI?.emphasizedRangeIndex ?? -1 - target?.emphasizeAPI?.highlightNext() - if let textViewController = target as? TextViewController, - let emphasizeAPI = target?.emphasizeAPI { - if emphasizeAPI.emphasizedRanges.isEmpty { - // Show "no matches" bezel notification and play beep - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - } else { - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - let range = emphasizeAPI.emphasizedRanges[activeIndex].range - textViewController.textView.scrollToRange(range) - textViewController.setCursorPositions([CursorPosition(range: range)]) - - // Show bezel notification if we cycled from last to first match - if previousIndex == emphasizeAPI.emphasizedRanges.count - 1 && activeIndex == 0 { - BezelNotification.show( - symbolName: "arrow.triangle.capsulepath", - over: textViewController.textView - ) - } - } - } + findPanelNextButtonClicked() } func findPanelOnCancel() { - // Return focus to the editor and restore cursor - if let textViewController = target as? TextViewController { - // Get the current highlight range before doing anything else - var rangeToSelect: NSRange? - if let emphasizeAPI = target?.emphasizeAPI { - if !emphasizeAPI.emphasizedRanges.isEmpty { - // Get the active highlight range - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - rangeToSelect = emphasizeAPI.emphasizedRanges[activeIndex].range - } - } - - // Now hide the panel - if isShowingFindPanel { - hideFindPanel() - } - - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // First make the text view first responder - self.view.window?.makeFirstResponder(textViewController.textView) - - // If we had an active highlight, select it - if let rangeToSelect = rangeToSelect { - // Set the selection first - textViewController.textView.selectionManager.setSelectedRanges([rangeToSelect]) - textViewController.setCursorPositions([CursorPosition(range: rangeToSelect)]) - textViewController.textView.scrollToRange(rangeToSelect) - - // Then clear highlights after a short delay to ensure selection is set - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.target?.emphasizeAPI?.removeEmphasizeLayers() - textViewController.textView.needsDisplay = true - } - } else if let currentPosition = textViewController.cursorPositions.first { - // Otherwise ensure cursor is visible at last position - textViewController.textView.scrollToRange(currentPosition.range) - textViewController.textView.selectionManager.setSelectedRanges([currentPosition.range]) - self.target?.emphasizeAPI?.removeEmphasizeLayers() - } - } + if isShowingFindPanel { + hideFindPanel() } } - func findPanelDidUpdate(_ searchText: String) { - // Only perform search if we're not handling a mouse click in the text view + 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 emphasis layers without searching - target?.emphasizeAPI?.removeEmphasizeLayers() - findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + // If the text view has focus, just clear visual emphases but keep matches in memory + target?.emphasisManager?.removeEmphases(for: "find") + // Re-add the current active emphasis without visual emphasis + if let emphases = target?.emphasisManager?.getEmphases(for: "find"), + let activeEmphasis = emphases.first(where: { !$0.inactive }) { + target?.emphasisManager?.addEmphasis( + Emphasis( + range: activeEmphasis.range, + style: .standard, + flash: false, + inactive: false, + select: true + ), + for: "find" + ) + } return } - searchFile(query: searchText) + + // Clear existing emphases before performing new find + target?.emphasisManager?.removeEmphases(for: "find") + find(text: text) } func findPanelPrevButtonClicked() { - let previousIndex = target?.emphasizeAPI?.emphasizedRangeIndex ?? -1 - target?.emphasizeAPI?.highlightPrevious() - if let textViewController = target as? TextViewController, - let emphasizeAPI = target?.emphasizeAPI, - !emphasizeAPI.emphasizedRanges.isEmpty { - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - let range = emphasizeAPI.emphasizedRanges[activeIndex].range - textViewController.textView.scrollToRange(range) - textViewController.setCursorPositions([CursorPosition(range: range)]) - - // Show bezel notification if we cycled from first to last match - if previousIndex == 0 && activeIndex == emphasizeAPI.emphasizedRanges.count - 1 { - BezelNotification.show( - symbolName: "arrow.trianglehead.bottomleft.capsulepath.clockwise", - over: textViewController.textView - ) - } + guard let textViewController = target as? TextViewController, + let emphasisManager = target?.emphasisManager else { return } + + // Check if there are any matches + if findMatches.isEmpty { + return } - } - func findPanelNextButtonClicked() { - let previousIndex = target?.emphasizeAPI?.emphasizedRangeIndex ?? -1 - target?.emphasizeAPI?.highlightNext() - if let textViewController = target as? TextViewController, - let emphasizeAPI = target?.emphasizeAPI, - !emphasizeAPI.emphasizedRanges.isEmpty { - let activeIndex = emphasizeAPI.emphasizedRangeIndex ?? 0 - let range = emphasizeAPI.emphasizedRanges[activeIndex].range - textViewController.textView.scrollToRange(range) - textViewController.setCursorPositions([CursorPosition(range: range)]) - - // Show bezel notification if we cycled from last to first match - if previousIndex == emphasizeAPI.emphasizedRanges.count - 1 && activeIndex == 0 { - BezelNotification.show( - symbolName: "arrow.triangle.capsulepath", - over: textViewController.textView - ) - } + // 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 + ) } - } - func searchFile(query: String, respectCursorPosition: Bool = true) { - guard let target = target, - let emphasizeAPI = target.emphasizeAPI else { - findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + // 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: "find") + + emphasisManager.addEmphasis( + Emphasis( + range: newActiveRange, + style: .standard, + flash: true, + inactive: false, + select: true + ), + for: "find" + ) + return } - // Clear highlights and return if query is empty - if query.isEmpty { - emphasizeAPI.removeEmphasizeLayers() - findPanel.searchDelegate?.findPanelUpdateMatchCount(0) - 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, + select: index == currentFindMatchIndex + ) } - let searchOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: query) + // Replace all emphases to update state + emphasisManager.replaceEmphases(updatedEmphases, for: "find") + } - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: searchOptions) else { - emphasizeAPI.removeEmphasizeLayers() - findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + 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 } - let text = target.text - let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) - guard !matches.isEmpty else { - emphasizeAPI.removeEmphasizeLayers() - findPanel.searchDelegate?.findPanelUpdateMatchCount(0) - 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 + ) } - let searchResults = matches.map { $0.range } - findPanel.searchDelegate?.findPanelUpdateMatchCount(searchResults.count) + // 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] - // Get the nearest match to either the cursor or visible area - let activeIndex = respectCursorPosition ? getNearestHighlightIndex(matchRanges: searchResults) ?? 0 : 0 + // Clear existing emphases before adding the flash + emphasisManager.removeEmphases(for: "find") - emphasizeAPI.emphasizeRanges(ranges: searchResults, activeIndex: activeIndex) + emphasisManager.addEmphasis( + Emphasis( + range: newActiveRange, + style: .standard, + flash: true, + inactive: false, + select: true + ), + for: "find" + ) - // Only set cursor position if we're actively searching (not when clearing) - if !query.isEmpty { - // Always select the active highlight - target.setCursorPositions([CursorPosition(range: searchResults[activeIndex])]) + return } - } - /// Finds the index of the nearest emphasised match range relative to the cursor or visible text range. - /// - /// - Parameter matchRanges: An array of `NSRange` representing the emphasised match locations. - /// - Returns: The index of the nearest match in `matchRanges`, or `nil` if no matches are found. - private func getNearestHighlightIndex(matchRanges: borrowing [NSRange]) -> Int? { - guard !matchRanges.isEmpty, - let textViewController = target as? TextViewController, - let textView = textViewController.textView, - let visibleRange = textView.visibleTextRange else { return nil } - - // Determine target position based on cursor visibility - let targetPosition: Int - if let cursorPosition = textViewController.cursorPositions.first?.range.location, - visibleRange.contains(cursorPosition) { - targetPosition = cursorPosition - } else { - targetPosition = visibleRange.location + // 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, + select: index == currentFindMatchIndex + ) } + // Replace all emphases to update state + emphasisManager.replaceEmphases(updatedEmphases, for: "find") + } + + func find(text: String) { + findText = text + performFind(query: text) + addEmphases() + } + + 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 + // Binary search for the nearest match var left = 0, right = matchRanges.count - 1 var bestIndex: Int? @@ -379,8 +461,11 @@ extension FindViewController: FindPanelDelegate { return bestIndex } + // 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 search query is all lowercase + // 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 } @@ -390,7 +475,6 @@ extension FindViewController: FindPanelDelegate { } func findPanelClearEmphasis() { - target?.emphasizeAPI?.removeEmphasizeLayers() - findPanel.searchDelegate?.findPanelUpdateMatchCount(0) + target?.emphasisManager?.removeEmphases(for: "find") } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index 887eb2f96..a6d4b8c17 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -13,18 +13,21 @@ import Combine final class FindPanel: NSView { static let height: CGFloat = 28 - weak var searchDelegate: FindPanelDelegate? + 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.searchDelegate = delegate + self.findDelegate = delegate self.textView = textView super.init(frame: .zero) - viewModel = FindPanelViewModel(delegate: searchDelegate) + viewModel = FindPanelViewModel(delegate: findDelegate) + viewModel.findText = findQueryText // Initialize with stored value hostingView = NSHostingView(rootView: FindPanelView(viewModel: viewModel)) hostingView.translatesAutoresizingMaskIntoConstraints = false @@ -52,7 +55,7 @@ final class FindPanel: NSView { super.viewDidMoveToSuperview() if !isViewReady && superview != nil { isViewReady = true - viewModel.startObservingSearchText() + viewModel.startObservingFindText() } } @@ -76,6 +79,25 @@ final class FindPanel: NSView { return true } + // MARK: - Event Monitor Management + + func addEventMonitor() { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in + if event.keyCode == 53 { // if esc pressed + self?.cancel() + return nil // do not play "beep" sound + } + return event + } + } + + func removeEventMonitor() { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } + // MARK: - Public Methods func cancel() { @@ -85,4 +107,12 @@ final class FindPanel: NSView { 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/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index dba32d5d6..f07f00131 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -17,7 +17,7 @@ struct FindPanelView: View { HStack(spacing: 5) { PanelTextField( "Search...", - text: $viewModel.searchText, + text: $viewModel.findText, leadingAccessories: { Image(systemName: "magnifyingglass") .padding(.leading, 8) @@ -25,12 +25,15 @@ struct FindPanelView: View { .font(.system(size: 12)) .frame(width: 16, height: 20) }, - helperText: viewModel.searchText.isEmpty + helperText: viewModel.findText.isEmpty ? nil : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", clearable: true ) .focused($isFocused) + .onChange(of: viewModel.findText) { newValue in + viewModel.onFindTextChange(newValue) + } .onChange(of: viewModel.isFocused) { newValue in isFocused = newValue if !newValue { @@ -72,15 +75,5 @@ struct FindPanelView: View { .padding(.horizontal, 5) .frame(height: FindPanel.height) .background(.bar) - .onAppear { - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { (event) -> NSEvent? in - if event.keyCode == 53 { // if esc pressed - viewModel.onCancel() - return nil // do not play "beep" sound - } - - return event - } - } } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index a51d9f906..0e77cbda4 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -9,23 +9,24 @@ import SwiftUI import Combine class FindPanelViewModel: ObservableObject { - weak var delegate: FindPanelDelegate? - @Published var isFocused: Bool = false - @Published var searchText: String = "" + @Published var findText: String = "" @Published var matchCount: Int = 0 - private var cancellables = Set() + @Published var isFocused: Bool = false + + private weak var delegate: FindPanelDelegate? init(delegate: FindPanelDelegate?) { self.delegate = delegate } - func startObservingSearchText() { - // Set up observer for searchText changes - $searchText - .sink { [weak self] newValue in - self?.delegate?.findPanelDidUpdate(newValue) - } - .store(in: &cancellables) + func startObservingFindText() { + if !findText.isEmpty { + delegate?.findPanelDidUpdate(findText) + } + } + + func onFindTextChange(_ text: String) { + delegate?.findPanelDidUpdate(text) } func onSubmit() { @@ -33,21 +34,15 @@ class FindPanelViewModel: ObservableObject { } func onCancel() { - setFocus(false) // Remove focus from search field - delegate?.findPanelOnCancel() // Call delegate first - searchText = "" // Clear the search text last - } - - func prevButtonClicked() { - delegate?.findPanelPrevButtonClicked() - } - - func nextButtonClicked() { - delegate?.findPanelNextButtonClicked() + delegate?.findPanelOnCancel() } 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) { @@ -57,4 +52,12 @@ class FindPanelViewModel: ObservableObject { func removeEmphasis() { delegate?.findPanelClearEmphasis() } + + func prevButtonClicked() { + delegate?.findPanelPrevButtonClicked() + } + + func nextButtonClicked() { + delegate?.findPanelNextButtonClicked() + } } diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 7513df574..8570ec877 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -62,8 +62,7 @@ enum Mock { isEditable: true, isSelectable: true, letterSpacing: 1.0, - useSystemCursor: false, - bracketPairHighlight: .flash + useSystemCursor: false ) } diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index cf4139df9..bfd92ce6f 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -31,7 +31,7 @@ final class TextViewControllerTests: XCTestCase { isSelectable: true, letterSpacing: 1.0, useSystemCursor: false, - bracketPairHighlight: .flash + bracketPairEmphasis: .flash ) controller.loadView() @@ -225,26 +225,26 @@ final class TextViewControllerTests: XCTestCase { controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() let _ = controller.textView.becomeFirstResponder() - controller.bracketPairHighlight = nil + controller.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - controller.bracketPairHighlight = .bordered(color: .black) + controller.bracketPairEmphasis = .bordered(color: .black) controller.textView.setNeedsDisplay() controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - controller.bracketPairHighlight = .underline(color: .black) + controller.bracketPairEmphasis = .underline(color: .black) controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - controller.bracketPairHighlight = .flash + controller.bracketPairEmphasis = .flash controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) From 0695d43ac236e734f4d6b9bbebf62b1250e02762 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 27 Mar 2025 22:02:06 -0500 Subject: [PATCH 19/37] Fixed merge conflict issue. --- .../Find/FindViewController.swift | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 245afccd4..208642c38 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -436,29 +436,36 @@ extension FindViewController: FindPanelDelegate { guard let cursorPosition = target?.cursorPositions.first else { return nil } let start = cursorPosition.range.location - // Binary search for the nearest match - var left = 0, right = matchRanges.count - 1 - var bestIndex: Int? - var bestDiff = Int.max + 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 - targetPosition) + 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 } - if midStart < targetPosition { + // Move left or right based on the cursor position + if midStart < start { left = mid + 1 } else { right = mid - 1 } } - return bestIndex + return bestIndex >= 0 ? bestIndex : nil } // Only re-find the part of the file that changed upwards From 9d493df3b2f21c5a1cda504cd1e545c9575b604a Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sat, 29 Mar 2025 10:56:31 -0500 Subject: [PATCH 20/37] Unified keybinding observer logic which fixed a bug where find keybindings didnt work while find panel was in focus preventing it from being dismissed. --- .../Views/ContentView.swift | 2 +- .../TextViewController+LoadView.swift | 23 +++++++------------ 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 52001f502..1000e3acb 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -21,7 +21,7 @@ struct ContentView: View { @State private var theme: EditorTheme = .light @State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) @AppStorage("wrapLines") private var wrapLines: Bool = true - @State private var cursorPositions: [CursorPosition] = [] + @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] @AppStorage("systemCursor") private var useSystemCursor: Bool = false @State private var isInLongParse = false @State private var treeSitterClient = TreeSitterClient() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 7041d0ec4..b1c4729d2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -129,21 +129,7 @@ extension TextViewController { eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in guard self?.view.window?.firstResponder == self?.textView else { return event } let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - - switch (modifierFlags, event.charactersIgnoringModifiers?.lowercased()) { - case (.command, "/"): - self?.handleCommandSlash() - return nil - case (.command, "f"): - _ = self?.textView.resignFirstResponder() - self?.searchController?.showFindPanel() - return nil - case ([], "\u{1b}"): // Escape key - self?.searchController?.findPanel.cancel() - return nil - default: - return event - } + return self?.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) } } @@ -160,6 +146,13 @@ extension TextViewController { case (commandKey, "]"): handleIndent() return nil + case (commandKey, "f"): + _ = self.textView.resignFirstResponder() + self.searchController?.showFindPanel() + return nil + case (0, "\u{1b}"): // Escape key + self.searchController?.findPanel.cancel() + return nil case (_, _): return event } From 813531e6d9e2928e5b253f6461f65b3a4f18e5f8 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Sun, 30 Mar 2025 23:02:38 -0500 Subject: [PATCH 21/37] Fixed issue where keybindings would apply to other windows editors. --- .../Controller/TextViewController+LoadView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index b1c4729d2..523bc8b04 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -127,9 +127,17 @@ extension TextViewController { func setUpKeyBindings(eventMonitor: inout Any?) { eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in - guard self?.view.window?.firstResponder == self?.textView else { return event } + guard let self = self else { return event } + + // Check if this window is key and if the text view is the first responder + let isKeyWindow = self.view.window?.isKeyWindow ?? false + let isFirstResponder = self.view.window?.firstResponder === self.textView + + // Only handle commands if this is the key window and text view is first responder + guard isKeyWindow && isFirstResponder else { return event } + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return self?.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) + return self.handleCommand(event: event, modifierFlags: modifierFlags.rawValue) } } From 114c6e989101572d8b6f59d9847bfdd1f6be97ab Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 31 Mar 2025 08:20:39 -0500 Subject: [PATCH 22/37] Remove Unused Variable --- .../Controller/TextViewController+EmphasizeBracket.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift index 6ed9c58cf..fe6d03102 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift @@ -113,8 +113,7 @@ extension TextViewController { /// - location: The location of the character to emphasize /// - scrollToRange: Set to true to scroll to the given range when emphasizing. Defaults to `false`. private func emphasizeCharacter(_ location: Int, scrollToRange: Bool = false) { - guard let bracketPairEmphasis = bracketPairEmphasis, - let rectToEmphasize = textView.layoutManager.rectForOffset(location) else { + guard let bracketPairEmphasis = bracketPairEmphasis else { return } From 56f37c0a2a1f49d38c05af71fd3c281484cd8548 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Wed, 2 Apr 2025 22:34:01 -0500 Subject: [PATCH 23/37] Renamed find panel cancel actions to dismiss. --- .../Views/ContentView.swift | 32 +++++++++++-------- .../TextViewController+LoadView.swift | 2 +- .../Find/FindPanelDelegate.swift | 2 +- .../Find/FindViewController.swift | 19 ++++------- .../Find/PanelView/FindPanel.swift | 6 ++-- .../Find/PanelView/FindPanelView.swift | 2 +- .../Find/PanelView/FindPanelViewModel.swift | 4 +-- Tests/CodeEditSourceEditorTests/Mock.swift | 3 +- 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 1000e3acb..e40d0c905 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -24,6 +24,7 @@ struct ContentView: View { @State private var cursorPositions: [CursorPosition] = [.init(line: 1, column: 1)] @AppStorage("systemCursor") private var useSystemCursor: Bool = false @State private var isInLongParse = false + @State private var settingsIsPresented: Bool = false @State private var treeSitterClient = TreeSitterClient() init(document: Binding, fileURL: URL?) { @@ -49,21 +50,24 @@ struct ContentView: View { ) .overlay(alignment: .bottom) { HStack { - Toggle("Wrap Lines", isOn: $wrapLines) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - if #available(macOS 14, *) { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .toggleStyle(.button) - .buttonStyle(.accessoryBar) - } else { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .disabled(true) - .help("macOS 14 required") - .toggleStyle(.button) - .buttonStyle(.accessoryBar) + Menu { + Toggle("Wrap Lines", isOn: $wrapLines) + if #available(macOS 14, *) { + Toggle("Use System Cursor", isOn: $useSystemCursor) + } else { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .disabled(true) + .help("macOS 14 required") + } + } label: {} + .background { + Image(systemName: "switch.2") + .foregroundStyle(.secondary) + .font(.system(size: 13.5, weight: .regular)) } - + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: 18, alignment: .center) Spacer() Group { if isInLongParse { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 523bc8b04..22f2f171b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -159,7 +159,7 @@ extension TextViewController { self.searchController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.searchController?.findPanel.cancel() + self.searchController?.findPanel.dismiss() return nil case (_, _): return event diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift index 523d8fcd9..2fb440929 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift @@ -9,7 +9,7 @@ import Foundation protocol FindPanelDelegate: AnyObject { func findPanelOnSubmit() - func findPanelOnCancel() + func findPanelOnDismiss() func findPanelDidUpdate(_ searchText: String) func findPanelPrevButtonClicked() func findPanelNextButtonClicked() diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 208642c38..b29e50792 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -16,9 +16,7 @@ final class FindViewController: NSViewController { private var findMatches: [NSRange] = [] private var currentFindMatchIndex: Int = 0 private var findText: String = "" - private var findPanelVerticalConstraint: NSLayoutConstraint! - private(set) public var isShowingFindPanel: Bool = false init(target: FindPanelTarget, childView: NSView) { @@ -178,15 +176,6 @@ final class FindViewController: NSViewController { // MARK: - Toggle find panel extension FindViewController { - /// Toggle the find panel - func toggleFindPanel() { - if isShowingFindPanel { - hideFindPanel() - } else { - showFindPanel() - } - } - /// Show the find panel func showFindPanel(animated: Bool = true) { if isShowingFindPanel { @@ -260,9 +249,15 @@ extension FindViewController: FindPanelDelegate { findPanelNextButtonClicked() } - func findPanelOnCancel() { + 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) + } + } } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index a6d4b8c17..e2b8e619a 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -84,7 +84,7 @@ final class FindPanel: NSView { func addEventMonitor() { eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in if event.keyCode == 53 { // if esc pressed - self?.cancel() + self?.dismiss() return nil // do not play "beep" sound } return event @@ -100,8 +100,8 @@ final class FindPanel: NSView { // MARK: - Public Methods - func cancel() { - viewModel.onCancel() + func dismiss() { + viewModel.onDismiss() } func updateMatchCount(_ count: Int) { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index f07f00131..d18b33cc5 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -65,7 +65,7 @@ struct FindPanelView: View { } .controlGroupStyle(PanelControlGroupStyle()) .fixedSize() - Button(action: viewModel.onCancel) { + Button(action: viewModel.onDismiss) { Text("Done") .padding(.horizontal, 5) } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift index 0e77cbda4..e8435f7a8 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift @@ -33,8 +33,8 @@ class FindPanelViewModel: ObservableObject { delegate?.findPanelOnSubmit() } - func onCancel() { - delegate?.findPanelOnCancel() + func onDismiss() { + delegate?.findPanelOnDismiss() } func setFocus(_ focused: Bool) { diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 8570ec877..173f1cad3 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -62,7 +62,8 @@ enum Mock { isEditable: true, isSelectable: true, letterSpacing: 1.0, - useSystemCursor: false + useSystemCursor: false, + bracketPairEmphasis: .flash ) } From 40b7660829108d0c1db63b27d28958d1d0604539 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 3 Apr 2025 09:19:07 -0500 Subject: [PATCH 24/37] Fixed SwiftLint errors and split files up. --- .../CodeEditSourceEditor.swift | 39 +- .../Find/FindViewController+Delegate.swift | 191 +++++++++ .../Find/FindViewController+Operations.swift | 125 ++++++ .../Find/FindViewController+Toggle.swift | 95 +++++ .../Find/FindViewController.swift | 377 +----------------- 5 files changed, 442 insertions(+), 385 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift create mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 85652765d..d9d8a9a0f 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -263,16 +263,17 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// Update the parameters of the controller. /// - Parameter controller: The controller to update. func updateControllerParams(controller: TextViewController) { + updateTextProperties(controller) + updateEditorProperties(controller) + updateThemeAndLanguage(controller) + updateHighlighting(controller) + } + + private func updateTextProperties(_ controller: TextViewController) { if controller.font != font { controller.font = font } - controller.wrapLines = wrapLines - controller.useThemeBackground = useThemeBackground - controller.lineHeightMultiple = lineHeight - controller.editorOverscroll = editorOverscroll - controller.contentInsets = contentInsets - if controller.isEditable != isEditable { controller.isEditable = isEditable } @@ -280,14 +281,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if controller.isSelectable != isSelectable { controller.isSelectable = isSelectable } + } - if controller.language.id != language.id { - controller.language = language - } - - if controller.theme != theme { - controller.theme = theme - } + private func updateEditorProperties(_ controller: TextViewController) { + controller.wrapLines = wrapLines + controller.useThemeBackground = useThemeBackground + controller.lineHeightMultiple = lineHeight + controller.editorOverscroll = editorOverscroll + controller.contentInsets = contentInsets if controller.indentOption != indentOption { controller.indentOption = indentOption @@ -304,7 +305,19 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { if controller.useSystemCursor != useSystemCursor { controller.useSystemCursor = useSystemCursor } + } + + private func updateThemeAndLanguage(_ controller: TextViewController) { + if controller.language.id != language.id { + controller.language = language + } + + if controller.theme != theme { + controller.theme = theme + } + } + private func updateHighlighting(_ controller: TextViewController) { if !areHighlightProvidersEqual(controller: controller) { controller.setHighlightProviders(highlightProviders) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift new file mode 100644 index 000000000..a783a8446 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift @@ -0,0 +1,191 @@ +// +// 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: "find") + // Re-add the current active emphasis without visual emphasis + if let emphases = target?.emphasisManager?.getEmphases(for: "find"), + let activeEmphasis = emphases.first(where: { !$0.inactive }) { + target?.emphasisManager?.addEmphasis( + Emphasis( + range: activeEmphasis.range, + style: .standard, + flash: false, + inactive: false, + select: true + ), + for: "find" + ) + } + return + } + + // Clear existing emphases before performing new find + target?.emphasisManager?.removeEmphases(for: "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: "find") + + emphasisManager.addEmphasis( + Emphasis( + range: newActiveRange, + style: .standard, + flash: true, + inactive: false, + select: true + ), + for: "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, + select: index == currentFindMatchIndex + ) + } + + // Replace all emphases to update state + emphasisManager.replaceEmphases(updatedEmphases, for: "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: "find") + + emphasisManager.addEmphasis( + Emphasis( + range: newActiveRange, + style: .standard, + flash: true, + inactive: false, + select: true + ), + for: "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, + select: index == currentFindMatchIndex + ) + } + + // Replace all emphases to update state + emphasisManager.replaceEmphases(updatedEmphases, for: "find") + } + + func findPanelUpdateMatchCount(_ count: Int) { + findPanel.updateMatchCount(count) + } + + func findPanelClearEmphasis() { + target?.emphasisManager?.removeEmphases(for: "find") + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift new file mode 100644 index 000000000..717d6cd51 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -0,0 +1,125 @@ +// +// 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(query: text) + addEmphases() + } + + func performFind(query: String) { + // 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 query.isEmpty { + findPanel.findDelegate?.findPanelUpdateMatchCount(0) + findMatches = [] + currentFindMatchIndex = 0 + return + } + + let findOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: query) + + 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: "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, + select: index == currentFindMatchIndex + ) + } + + // Add all emphases + emphasisManager.addEmphases(emphases, for: "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 new file mode 100644 index 000000000..585856aef --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -0,0 +1,95 @@ +// +// FindViewController+Toggle.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 4/3/25. +// + +import AppKit + +extension FindViewController { + /// Show the find panel + func showFindPanel(animated: Bool = true) { + if isShowingFindPanel { + // If panel is already showing, just focus the text field + _ = findPanel?.becomeFirstResponder() + return + } + + isShowingFindPanel = true + + let updates: () -> Void = { [self] in + // 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) + setFindPanelConstraintShow() + } + + if animated { + withAnimation(updates) + } else { + updates() + } + + _ = findPanel?.becomeFirstResponder() + findPanel?.addEventMonitor() + } + + /// Hide the find panel + func hideFindPanel(animated: Bool = true) { + isShowingFindPanel = false + _ = findPanel?.resignFirstResponder() + findPanel?.removeEventMonitor() + + let updates: () -> Void = { [self] in + target?.findPanelWillHide(panelHeight: FindPanel.height) + setFindPanelConstraintHide() + } + + if animated { + withAnimation(updates) + } else { + updates() + } + + // Set first responder back to text view + if let textViewController = target as? TextViewController { + _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) + } + } + + /// Runs the `animatable` callback in an animation context with implicit animation enabled. + /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this + /// callback to have them animated. + private func withAnimation(_ animatable: () -> Void) { + NSAnimationContext.runAnimationGroup { animator in + animator.duration = 0.2 + animator.allowsImplicitAnimation = true + + animatable() + + view.updateConstraints() + view.layoutSubtreeIfNeeded() + } + } + + /// Sets the find panel constraint to show the find panel. + /// Can be animated using implicit animation. + private func setFindPanelConstraintShow() { + // Update the find panel's top to be equal to the view's top. + findPanelVerticalConstraint.constant = view.safeAreaInsets.top + findPanelVerticalConstraint.isActive = true + } + + /// Sets the find panel constraint to hide the find panel. + /// Can be animated using implicit animation. + private func setFindPanelConstraintHide() { + // Update the find panel's top anchor to be equal to it's negative height, hiding it above the view. + + // 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 = view.safeAreaInsets.top - (FindPanel.height * 3) + findPanelVerticalConstraint.isActive = true + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index b29e50792..700c72147 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -13,11 +13,11 @@ final class FindViewController: NSViewController { weak var target: FindPanelTarget? var childView: NSView var findPanel: FindPanel! - private var findMatches: [NSRange] = [] - private var currentFindMatchIndex: Int = 0 - private var findText: String = "" - private var findPanelVerticalConstraint: NSLayoutConstraint! - private(set) public var isShowingFindPanel: Bool = false + var findMatches: [NSRange] = [] + var currentFindMatchIndex: Int = 0 + var findText: String = "" + var findPanelVerticalConstraint: NSLayoutConstraint! + var isShowingFindPanel: Bool = false init(target: FindPanelTarget, childView: NSView) { self.target = target @@ -47,65 +47,6 @@ final class FindViewController: NSViewController { } } - private func performFind(query: String) { - // 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 query.isEmpty { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - let findOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: query) - - 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: "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, - select: index == currentFindMatchIndex - ) - } - - // Add all emphases - emphasisManager.addEmphases(emphases, for: "find") - } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -172,311 +113,3 @@ final class FindViewController: NSViewController { findPanelVerticalConstraint.isActive = true } } - -// MARK: - Toggle find panel - -extension FindViewController { - /// Show the find panel - func showFindPanel(animated: Bool = true) { - if isShowingFindPanel { - // If panel is already showing, just focus the text field - _ = findPanel?.becomeFirstResponder() - return - } - - isShowingFindPanel = true - - let updates: () -> Void = { [self] in - // 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) - setFindPanelConstraintShow() - } - - if animated { - withAnimation(updates) - } else { - updates() - } - - _ = findPanel?.becomeFirstResponder() - findPanel?.addEventMonitor() - } - - /// Hide the find panel - func hideFindPanel(animated: Bool = true) { - isShowingFindPanel = false - _ = findPanel?.resignFirstResponder() - findPanel?.removeEventMonitor() - - let updates: () -> Void = { [self] in - target?.findPanelWillHide(panelHeight: FindPanel.height) - setFindPanelConstraintHide() - } - - if animated { - withAnimation(updates) - } else { - updates() - } - - // Set first responder back to text view - if let textViewController = target as? TextViewController { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) - } - } - - /// Runs the `animatable` callback in an animation context with implicit animation enabled. - /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this - /// callback to have them animated. - private func withAnimation(_ animatable: () -> Void) { - NSAnimationContext.runAnimationGroup { animator in - animator.duration = 0.2 - animator.allowsImplicitAnimation = true - - animatable() - - view.updateConstraints() - view.layoutSubtreeIfNeeded() - } - } -} - -// MARK: - Find Panel Delegate - -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: "find") - // Re-add the current active emphasis without visual emphasis - if let emphases = target?.emphasisManager?.getEmphases(for: "find"), - let activeEmphasis = emphases.first(where: { !$0.inactive }) { - target?.emphasisManager?.addEmphasis( - Emphasis( - range: activeEmphasis.range, - style: .standard, - flash: false, - inactive: false, - select: true - ), - for: "find" - ) - } - return - } - - // Clear existing emphases before performing new find - target?.emphasisManager?.removeEmphases(for: "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: "find") - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - select: true - ), - for: "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, - select: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: "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: "find") - - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - select: true - ), - for: "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, - select: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: "find") - } - - func find(text: String) { - findText = text - performFind(query: text) - addEmphases() - } - - 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 - } - - func findPanelUpdateMatchCount(_ count: Int) { - findPanel.updateMatchCount(count) - } - - func findPanelClearEmphasis() { - target?.emphasisManager?.removeEmphases(for: "find") - } -} From 0980ef05a5cfef75f5010efaa4fc2a180cfc2030 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 3 Apr 2025 09:31:05 -0500 Subject: [PATCH 25/37] Update tests.sh --- .github/scripts/tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/tests.sh b/.github/scripts/tests.sh index af8440aa1..47c637a82 100755 --- a/.github/scripts/tests.sh +++ b/.github/scripts/tests.sh @@ -16,6 +16,6 @@ export LC_CTYPE=en_US.UTF-8 set -o pipefail && arch -"${ARCH}" xcodebuild \ -scheme CodeEditSourceEditor \ -derivedDataPath ".build" \ - -destination "platform=macos,arch=${ARCH}" \ + -destination "platform=macos,arch=${ARCH},name=My Mac" \ -skipPackagePluginValidation \ clean test | xcpretty From 3ec17ecb108b77daf6e6eb08b0ae771ba0e0d61f Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 3 Apr 2025 09:35:31 -0500 Subject: [PATCH 26/37] Update tests.sh --- .github/scripts/tests.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/scripts/tests.sh b/.github/scripts/tests.sh index 47c637a82..4e3367272 100755 --- a/.github/scripts/tests.sh +++ b/.github/scripts/tests.sh @@ -1,9 +1,8 @@ #!/bin/bash ARCH="" - -if [ $1 = "arm" ] -then + +if [ "$1" = "arm" ]; then ARCH="arm64" else ARCH="x86_64" @@ -13,9 +12,18 @@ echo "Building with arch: ${ARCH}" export LC_CTYPE=en_US.UTF-8 +DEVICE_ID=$(xcrun xctrace list devices 2>/dev/null | grep -m1 "My Mac" | grep "${ARCH}" | awk -F '[()]' '{print $2}') + +if [ -z "$DEVICE_ID" ]; then + echo "Failed to find device ID for arch ${ARCH}" + exit 1 +fi + +echo "Using device ID: $DEVICE_ID" + set -o pipefail && arch -"${ARCH}" xcodebuild \ -scheme CodeEditSourceEditor \ -derivedDataPath ".build" \ - -destination "platform=macos,arch=${ARCH},name=My Mac" \ + -destination "id=${DEVICE_ID}" \ -skipPackagePluginValidation \ clean test | xcpretty From f74d2e899c80c9bc9ec69ead1b2d6f113feed990 Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Thu, 3 Apr 2025 09:41:05 -0500 Subject: [PATCH 27/37] Update tests.sh --- .github/scripts/tests.sh | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/scripts/tests.sh b/.github/scripts/tests.sh index 4e3367272..af8440aa1 100755 --- a/.github/scripts/tests.sh +++ b/.github/scripts/tests.sh @@ -1,8 +1,9 @@ #!/bin/bash ARCH="" - -if [ "$1" = "arm" ]; then + +if [ $1 = "arm" ] +then ARCH="arm64" else ARCH="x86_64" @@ -12,18 +13,9 @@ echo "Building with arch: ${ARCH}" export LC_CTYPE=en_US.UTF-8 -DEVICE_ID=$(xcrun xctrace list devices 2>/dev/null | grep -m1 "My Mac" | grep "${ARCH}" | awk -F '[()]' '{print $2}') - -if [ -z "$DEVICE_ID" ]; then - echo "Failed to find device ID for arch ${ARCH}" - exit 1 -fi - -echo "Using device ID: $DEVICE_ID" - set -o pipefail && arch -"${ARCH}" xcodebuild \ -scheme CodeEditSourceEditor \ -derivedDataPath ".build" \ - -destination "id=${DEVICE_ID}" \ + -destination "platform=macos,arch=${ARCH}" \ -skipPackagePluginValidation \ clean test | xcpretty From 1d75296edcf81e0cee3377444d9a07471279905b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:35:42 -0700 Subject: [PATCH 28/37] Use `selectInDocument` over `select` --- .../Find/FindViewController+Delegate.swift | 10 +++++----- .../Find/FindViewController+Operations.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift index a783a8446..1e4307388 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift @@ -48,7 +48,7 @@ extension FindViewController: FindPanelDelegate { style: .standard, flash: false, inactive: false, - select: true + selectInDocument: true ), for: "find" ) @@ -95,7 +95,7 @@ extension FindViewController: FindPanelDelegate { style: .standard, flash: true, inactive: false, - select: true + selectInDocument: true ), for: "find" ) @@ -110,7 +110,7 @@ extension FindViewController: FindPanelDelegate { style: .standard, flash: false, inactive: index != currentFindMatchIndex, - select: index == currentFindMatchIndex + selectInDocument: index == currentFindMatchIndex ) } @@ -158,7 +158,7 @@ extension FindViewController: FindPanelDelegate { style: .standard, flash: true, inactive: false, - select: true + selectInDocument: true ), for: "find" ) @@ -173,7 +173,7 @@ extension FindViewController: FindPanelDelegate { style: .standard, flash: false, inactive: index != currentFindMatchIndex, - select: index == currentFindMatchIndex + selectInDocument: index == currentFindMatchIndex ) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index 717d6cd51..0fb08f0fa 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -66,7 +66,7 @@ extension FindViewController { style: .standard, flash: false, inactive: index != currentFindMatchIndex, - select: index == currentFindMatchIndex + selectInDocument: index == currentFindMatchIndex ) } From 1dc6c3b9a03498b30163a537a2125346af84fd6c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 09:03:03 -0500 Subject: [PATCH 29/37] Replace Safe Area With Top Padding, Smoother Animations --- .../xcshareddata/swiftpm/Package.resolved | 4 +-- Package.swift | 2 +- .../TextViewController+LoadView.swift | 27 ++++++++++--------- .../Controller/TextViewController.swift | 8 ++++-- .../Find/FindViewController+Toggle.swift | 24 ++++++++++++----- .../Find/FindViewController.swift | 25 ++++------------- 6 files changed, 46 insertions(+), 44 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index afaca9f90..70b1e3e5a 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "1792167c751b6668b4743600d2cf73d2829dd18a", - "version" : "0.7.9" + "revision" : "02202a8d925dc902f18626e953b3447e320253d1", + "version" : "0.8.1" } }, { diff --git a/Package.swift b/Package.swift index a14b6e7a9..b1cd5e283 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.7.9" + from: "0.8.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 22f2f171b..28f8a4f3e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -29,11 +29,14 @@ extension TextViewController { for: .horizontal ) - let searchController = FindViewController(target: self, childView: scrollView) - addChild(searchController) - self.view.addSubview(searchController.view) - searchController.view.viewDidMoveToSuperview() - self.searchController = searchController + let findViewController = FindViewController(target: self, childView: scrollView) + addChild(findViewController) + self.findViewController = findViewController + self.view.addSubview(findViewController.view) + findViewController.view.viewDidMoveToSuperview() + self.findViewController = findViewController + + findViewController.topPadding = contentInsets?.top ?? view.safeAreaInsets.top if let _undoManager { textView.setUndoManager(_undoManager) @@ -46,10 +49,10 @@ extension TextViewController { setUpTextFormation() NSLayoutConstraint.activate([ - searchController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - searchController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - searchController.view.topAnchor.constraint(equalTo: view.topAnchor), - searchController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + findViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + findViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + findViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) if !cursorPositions.isEmpty { @@ -110,7 +113,7 @@ extension TextViewController { // Reset content insets and gutter position when appearance changes if let contentInsets = self.contentInsets { self.scrollView.contentInsets = contentInsets - if let searchController = self.searchController, searchController.isShowingFindPanel { + if let findViewController = self.findViewController, findViewController.isShowingFindPanel { self.scrollView.contentInsets.top += FindPanel.height } self.gutterView.frame.origin.y = -self.scrollView.contentInsets.top @@ -156,10 +159,10 @@ extension TextViewController { return nil case (commandKey, "f"): _ = self.textView.resignFirstResponder() - self.searchController?.showFindPanel() + self.findViewController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.searchController?.findPanel.dismiss() + self.findViewController?.findPanel.dismiss() return nil case (_, _): return event diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index ec856da4a..b430fd03e 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -20,7 +20,7 @@ public class TextViewController: NSViewController { // swiftlint:disable:next line_length public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") - weak var searchController: FindViewController? + weak var findViewController: FindViewController? var scrollView: NSScrollView! @@ -124,7 +124,11 @@ public class TextViewController: NSViewController { public var highlightProviders: [HighlightProviding] /// Optional insets to offset the text view in the scroll view by. - public var contentInsets: NSEdgeInsets? + public var contentInsets: NSEdgeInsets? { + didSet { + findViewController?.topPadding = contentInsets?.top ?? view.safeAreaInsets.top + } + } /// Whether or not text view is editable by user public var isEditable: Bool { diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 585856aef..84ec859b6 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -25,8 +25,13 @@ extension FindViewController { setFindPanelConstraintShow() } + // Smooth this animation + findPanel.isHidden = false + findPanelVerticalConstraint.constant = topPadding - FindPanel.height + view.layoutSubtreeIfNeeded() + if animated { - withAnimation(updates) + withAnimation(updates) { } } else { updates() } @@ -47,9 +52,12 @@ extension FindViewController { } if animated { - withAnimation(updates) + withAnimation(updates) { [weak self] in + self?.findPanel.isHidden = true + } } else { updates() + findPanel.isHidden = true } // Set first responder back to text view @@ -61,7 +69,7 @@ extension FindViewController { /// Runs the `animatable` callback in an animation context with implicit animation enabled. /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this /// callback to have them animated. - private func withAnimation(_ animatable: () -> Void) { + private func withAnimation(_ animatable: () -> Void, onComplete: @escaping () -> Void) { NSAnimationContext.runAnimationGroup { animator in animator.duration = 0.2 animator.allowsImplicitAnimation = true @@ -70,26 +78,28 @@ extension FindViewController { view.updateConstraints() view.layoutSubtreeIfNeeded() + } completionHandler: { + onComplete() } } /// Sets the find panel constraint to show the find panel. /// Can be animated using implicit animation. - private func setFindPanelConstraintShow() { + func setFindPanelConstraintShow() { // Update the find panel's top to be equal to the view's top. - findPanelVerticalConstraint.constant = view.safeAreaInsets.top + findPanelVerticalConstraint.constant = topPadding findPanelVerticalConstraint.isActive = true } /// Sets the find panel constraint to hide the find panel. /// Can be animated using implicit animation. - private func setFindPanelConstraintHide() { + func setFindPanelConstraintHide() { // Update the find panel's top anchor to be equal to it's negative height, hiding it above the view. // 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 = view.safeAreaInsets.top - (FindPanel.height * 3) + findPanelVerticalConstraint.constant = topPadding - FindPanel.height findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 700c72147..61a33775f 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -11,12 +11,17 @@ 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 topPadding: CGFloat = 0.0 + var childView: NSView var findPanel: FindPanel! var findMatches: [NSRange] = [] + var currentFindMatchIndex: Int = 0 var findText: String = "" var findPanelVerticalConstraint: NSLayoutConstraint! + var isShowingFindPanel: Bool = false init(target: FindPanelTarget, childView: NSView) { @@ -92,24 +97,4 @@ final class FindViewController: NSViewController { setFindPanelConstraintHide() } } - - /// Sets the find panel constraint to show the find panel. - /// Can be animated using implicit animation. - private func setFindPanelConstraintShow() { - // Update the find panel's top to be equal to the view's top. - findPanelVerticalConstraint.constant = view.safeAreaInsets.top - findPanelVerticalConstraint.isActive = true - } - - /// Sets the find panel constraint to hide the find panel. - /// Can be animated using implicit animation. - private func setFindPanelConstraintHide() { - // Update the find panel's top anchor to be equal to it's negative height, hiding it above the view. - - // 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 = view.safeAreaInsets.top - (FindPanel.height * 3) - findPanelVerticalConstraint.isActive = true - } } From ed430784e71315f071f1ef8e6f03bfd174a6417b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 09:41:09 -0500 Subject: [PATCH 30/37] Add Find Panel Height when Setting Up Scroll View --- .../Controller/TextViewController+LoadView.swift | 2 +- .../Controller/TextViewController+StyleViews.swift | 2 +- .../Controller/TextViewController.swift | 2 +- .../Find/FindViewController+Operations.swift | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 28f8a4f3e..edf2f7a15 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -36,7 +36,7 @@ extension TextViewController { findViewController.view.viewDidMoveToSuperview() self.findViewController = findViewController - findViewController.topPadding = contentInsets?.top ?? view.safeAreaInsets.top + findViewController.topPadding = contentInsets?.top if let _undoManager { textView.setUndoManager(_undoManager) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 9a0e6eec0..a5ca4d056 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -74,6 +74,6 @@ extension TextViewController { } else { scrollView.automaticallyAdjustsContentInsets = true } - scrollView.contentInsets.bottom = contentInsets?.bottom ?? 0 + scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 95962dba3..b53361492 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -130,7 +130,7 @@ public class TextViewController: NSViewController { /// Optional insets to offset the text view in the scroll view by. public var contentInsets: NSEdgeInsets? { didSet { - findViewController?.topPadding = contentInsets?.top ?? view.safeAreaInsets.top + findViewController?.topPadding = contentInsets?.top } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index 0fb08f0fa..f35eb3d71 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -11,11 +11,11 @@ import CodeEditTextView extension FindViewController { func find(text: String) { findText = text - performFind(query: text) + performFind() addEmphases() } - func performFind(query: String) { + func performFind() { // Don't find if target or emphasisManager isn't ready guard let target = target else { findPanel.findDelegate?.findPanelUpdateMatchCount(0) @@ -25,15 +25,15 @@ extension FindViewController { } // Clear emphases and return if query is empty - if query.isEmpty { + if findText.isEmpty { findPanel.findDelegate?.findPanelUpdateMatchCount(0) findMatches = [] currentFindMatchIndex = 0 return } - let findOptions: NSRegularExpression.Options = smartCase(str: query) ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: query) + 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) From 40994999eecfd0870316364184d33b103d80979e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 09:41:37 -0500 Subject: [PATCH 31/37] Clean Up Animation Code --- .../Find/FindViewController+Toggle.swift | 66 +++++++++++-------- .../Find/FindViewController.swift | 12 +++- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 84ec859b6..99645ce08 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -9,6 +9,12 @@ import AppKit extension FindViewController { /// Show the find panel + /// + /// Performs the following: + /// - Makes the find panel the first responder. + /// - Sets the find panel to be just outside the visible area (`resolvedTopPadding - FindPanel.height`). + /// - Animates the find panel into position (resolvedTopPadding). + /// - Makes the find panel the first responder. func showFindPanel(animated: Bool = true) { if isShowingFindPanel { // If panel is already showing, just focus the text field @@ -18,46 +24,40 @@ extension FindViewController { isShowingFindPanel = true - let updates: () -> Void = { [self] in + // Smooth out the animation by placing the find panel just outside the correct position before animating. + findPanel.isHidden = false + findPanelVerticalConstraint.constant = resolvedTopPadding - FindPanel.height + 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) setFindPanelConstraintShow() - } - - // Smooth this animation - findPanel.isHidden = false - findPanelVerticalConstraint.constant = topPadding - FindPanel.height - view.layoutSubtreeIfNeeded() - - if animated { - withAnimation(updates) { } - } else { - updates() - } + } onComplete: { } _ = findPanel?.becomeFirstResponder() findPanel?.addEventMonitor() } /// Hide the find panel + /// + /// Performs the following: + /// - Resigns the find panel from first responder. + /// - Animates the find panel just outside the visible area (`resolvedTopPadding - FindPanel.height`). + /// - Hides the find panel. + /// - Sets the text view to be the first responder. func hideFindPanel(animated: Bool = true) { isShowingFindPanel = false _ = findPanel?.resignFirstResponder() findPanel?.removeEventMonitor() - let updates: () -> Void = { [self] in + conditionalAnimated(animated) { target?.findPanelWillHide(panelHeight: FindPanel.height) setFindPanelConstraintHide() - } - - if animated { - withAnimation(updates) { [weak self] in - self?.findPanel.isHidden = true - } - } else { - updates() - findPanel.isHidden = true + } onComplete: { [weak self] in + self?.findPanel.isHidden = true } // Set first responder back to text view @@ -66,12 +66,26 @@ extension FindViewController { } } + /// Performs an animation with a completion handler, conditionally animating the changes. + /// - Parameters: + /// - animated: Determines if the changes are performed in an animation context. + /// - animatable: Perform the changes to be animated in this callback. Implicit animation will be enabled. + /// - onComplete: Called when the changes are complete, animated or not. + private func conditionalAnimated(_ animated: Bool, animatable: () -> Void, onComplete: @escaping () -> Void) { + if animated { + withAnimation(animatable, onComplete: onComplete) + } else { + animatable() + onComplete() + } + } + /// Runs the `animatable` callback in an animation context with implicit animation enabled. /// - Parameter animatable: The callback run in the animation context. Perform layout or view updates in this /// callback to have them animated. private func withAnimation(_ animatable: () -> Void, onComplete: @escaping () -> Void) { NSAnimationContext.runAnimationGroup { animator in - animator.duration = 0.2 + animator.duration = 0.15 animator.allowsImplicitAnimation = true animatable() @@ -87,7 +101,7 @@ extension FindViewController { /// Can be animated using implicit animation. func setFindPanelConstraintShow() { // Update the find panel's top to be equal to the view's top. - findPanelVerticalConstraint.constant = topPadding + findPanelVerticalConstraint.constant = resolvedTopPadding findPanelVerticalConstraint.isActive = true } @@ -99,7 +113,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 = topPadding - FindPanel.height + findPanelVerticalConstraint.constant = resolvedTopPadding - (FindPanel.height * 3) findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 61a33775f..4759e7ec3 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -12,7 +12,9 @@ import CodeEditTextView final class FindViewController: NSViewController { weak var target: FindPanelTarget? - var topPadding: CGFloat = 0.0 + /// 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? var childView: NSView var findPanel: FindPanel! @@ -24,6 +26,12 @@ final class FindViewController: NSViewController { 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 { + (topPadding ?? view.safeAreaInsets.top) + } + init(target: FindPanelTarget, childView: NSView) { self.target = target self.childView = childView @@ -48,7 +56,7 @@ final class FindViewController: NSViewController { @objc private func textDidChange() { // Only update if we have find text if !findText.isEmpty { - performFind(query: findText) + performFind() } } From 9ad415c05b9f5337d544638806152fb147e3b724 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 09:41:50 -0500 Subject: [PATCH 32/37] Rename --- ...+Delegate.swift => FindViewController+FindPanelDelegate.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/CodeEditSourceEditor/Find/{FindViewController+Delegate.swift => FindViewController+FindPanelDelegate.swift} (100%) diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Find/FindViewController+Delegate.swift rename to Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift From 08445eeef09e397506c21ed64ea83875a8ade27d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 09:42:01 -0500 Subject: [PATCH 33/37] Strongly Reference Self in Monitor --- .../CodeEditSourceEditor/Find/PanelView/FindPanel.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift index e2b8e619a..86506018e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -59,6 +59,10 @@ final class FindPanel: NSView { } } + deinit { + removeEventMonitor() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -82,9 +86,9 @@ final class FindPanel: NSView { // MARK: - Event Monitor Management func addEventMonitor() { - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in if event.keyCode == 53 { // if esc pressed - self?.dismiss() + self.dismiss() return nil // do not play "beep" sound } return event From 909eabc96d10f168e5220a9b594cfc6dec0885da Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 09:43:41 -0500 Subject: [PATCH 34/37] Update Package.resolved --- Package.resolved | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Package.resolved b/Package.resolved index b65636941..42e4cd76e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -71,6 +71,15 @@ "revision" : "8dc9148b46fcf93b08ea9d4ef9bdb5e4f700e008", "version" : "0.9.0" } + }, + { + "identity" : "tree-sitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter", + "state" : { + "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", + "version" : "0.23.2" + } } ], "version" : 2 From a7cb556522ec5e90cee512d2ae8e33c7d5ecb99d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:04:09 -0500 Subject: [PATCH 35/37] Use Constants For Emphasis Groups, Add `additionalInsets`, Correctly Update Insets, Fix Tests --- Package.resolved | 4 +- Package.swift | 2 +- .../CodeEditSourceEditor.swift | 9 +++ .../TextViewController+EmphasizeBracket.swift | 68 ++++++++++--------- .../TextViewController+LoadView.swift | 13 +--- .../TextViewController+StyleViews.swift | 4 ++ .../Controller/TextViewController.swift | 17 ++++- ...FindViewController+FindPanelDelegate.swift | 22 +++--- .../Find/FindViewController+Operations.swift | 4 +- .../Find/FindViewController.swift | 10 ++- .../Utils/EmphasisGroup.swift | 11 +++ .../TextViewControllerTests.swift | 65 ++++++++++++++---- 12 files changed, 156 insertions(+), 73 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift diff --git a/Package.resolved b/Package.resolved index 42e4cd76e..6446cba69 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "02202a8d925dc902f18626e953b3447e320253d1", - "version" : "0.8.1" + "revision" : "47faec9fb571c9c695897e69f0a4f08512ae682e", + "version" : "0.8.2" } }, { diff --git a/Package.swift b/Package.swift index b1cd5e283..c0d9431e2 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.8.1" + from: "0.8.2" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index d9d8a9a0f..8f1de115b 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -35,6 +35,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { /// built-in `TreeSitterClient` highlighter. /// - contentInsets: Insets to use to offset the content in the enclosing scroll view. Leave as `nil` to let the /// scroll view automatically adjust content insets. + /// - additionalTextInsets: An additional amount to inset the text of the editor by. /// - isEditable: A Boolean value that controls whether the text view allows the user to edit text. /// - isSelectable: A Boolean value that controls whether the text view allows the user to select text. If this /// value is true, and `isEditable` is false, the editor is selectable but not editable. @@ -59,6 +60,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { useThemeBackground: Bool = true, highlightProviders: [any HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets? = nil, + additionalTextInsets: NSEdgeInsets? = nil, isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, @@ -80,6 +82,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self.contentInsets = contentInsets + self.additionalTextInsets = additionalTextInsets self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing @@ -134,6 +137,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { useThemeBackground: Bool = true, highlightProviders: [any HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets? = nil, + additionalTextInsets: NSEdgeInsets? = nil, isEditable: Bool = true, isSelectable: Bool = true, letterSpacing: Double = 1.0, @@ -155,6 +159,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { self.cursorPositions = cursorPositions self.highlightProviders = highlightProviders self.contentInsets = contentInsets + self.additionalTextInsets = additionalTextInsets self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing @@ -181,6 +186,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var useThemeBackground: Bool private var highlightProviders: [any HighlightProviding] private var contentInsets: NSEdgeInsets? + private var additionalTextInsets: NSEdgeInsets? private var isEditable: Bool private var isSelectable: Bool private var letterSpacing: Double @@ -206,6 +212,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { useThemeBackground: useThemeBackground, highlightProviders: highlightProviders, contentInsets: contentInsets, + additionalTextInsets: additionalTextInsets, isEditable: isEditable, isSelectable: isSelectable, letterSpacing: letterSpacing, @@ -289,6 +296,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.lineHeightMultiple = lineHeight controller.editorOverscroll = editorOverscroll controller.contentInsets = contentInsets + controller.additionalTextInsets = additionalTextInsets if controller.indentOption != indentOption { controller.indentOption = indentOption @@ -339,6 +347,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.lineHeightMultiple == lineHeight && controller.editorOverscroll == editorOverscroll && controller.contentInsets == contentInsets && + controller.additionalTextInsets == additionalTextInsets && controller.language.id == language.id && controller.theme == theme && controller.indentOption == indentOption && diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift index fe6d03102..87a3c49cb 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+EmphasizeBracket.swift @@ -11,8 +11,8 @@ import CodeEditTextView extension TextViewController { /// Emphasizes bracket pairs using the current selection. internal func emphasizeSelectionPairs() { - guard bracketPairEmphasis != nil else { return } - textView.emphasisManager?.removeEmphases(for: "bracketPairs") + guard let bracketPairEmphasis else { return } + textView.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) for range in textView.selectionManager.textSelections.map({ $0.range }) { if range.isEmpty, range.location > 0, // Range is not the beginning of the document @@ -22,40 +22,46 @@ extension TextViewController { for pair in BracketPairs.emphasisValues { if precedingCharacter == pair.0 { // Walk forwards - if let characterIndex = findClosingPair( - pair.0, - pair.1, - from: range.location, - limit: min(NSMaxRange(textView.visibleTextRange ?? .zero) + 4096, - NSMaxRange(textView.documentRange)), - reverse: false - ) { - emphasizeCharacter(characterIndex) - if bracketPairEmphasis?.emphasizesSourceBracket ?? false { - emphasizeCharacter(range.location - 1) - } - } + emphasizeForwards(pair, range: range, emphasisType: bracketPairEmphasis) } else if precedingCharacter == pair.1 && range.location - 1 > 0 { // Walk backwards - if let characterIndex = findClosingPair( - pair.1, - pair.0, - from: range.location - 1, - limit: max((textView.visibleTextRange?.location ?? 0) - 4096, - textView.documentRange.location), - reverse: true - ) { - emphasizeCharacter(characterIndex) - if bracketPairEmphasis?.emphasizesSourceBracket ?? false { - emphasizeCharacter(range.location - 1) - } - } + emphasizeBackwards(pair, range: range, emphasisType: bracketPairEmphasis) } } } } } + private func emphasizeForwards(_ pair: (String, String), range: NSRange, emphasisType: BracketPairEmphasis) { + if let characterIndex = findClosingPair( + pair.0, + pair.1, + from: range.location, + limit: min((textView.visibleTextRange ?? .zero).max + 4096, textView.documentRange.max), + reverse: false + ) { + emphasizeCharacter(characterIndex) + if emphasisType.emphasizesSourceBracket { + emphasizeCharacter(range.location - 1) + } + } + } + + private func emphasizeBackwards(_ pair: (String, String), range: NSRange, emphasisType: BracketPairEmphasis) { + if let characterIndex = findClosingPair( + pair.1, + pair.0, + from: range.location - 1, + limit: max((textView.visibleTextRange?.location ?? 0) - 4096, textView.documentRange.location), + reverse: true + ) { + emphasizeCharacter(characterIndex) + if emphasisType.emphasizesSourceBracket { + emphasizeCharacter(range.location - 1) + } + } + } + /// # Dev Note /// It's interesting to note that this problem could trivially be turned into a monoid, and the locations of each /// pair start/end location determined when the view is loaded. It could then be parallelized for initial speed @@ -128,7 +134,7 @@ extension TextViewController { flash: true, inactive: false ), - for: "bracketPairs" + for: EmphasisGroup.brackets ) case .bordered(let borderColor): textView.emphasisManager?.addEmphasis( @@ -138,7 +144,7 @@ extension TextViewController { flash: false, inactive: false ), - for: "bracketPairs" + for: EmphasisGroup.brackets ) case .underline(let underlineColor): textView.emphasisManager?.addEmphasis( @@ -148,7 +154,7 @@ extension TextViewController { flash: false, inactive: false ), - for: "bracketPairs" + for: EmphasisGroup.brackets ) } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index edf2f7a15..a4e2cf76d 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -77,9 +77,7 @@ extension TextViewController { ) { [weak self] _ in self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) self?.gutterView.needsDisplay = true - if self?.bracketPairEmphasis == .flash { - self?.emphasisManager?.removeEmphases(for: "bracketPairs") - } + self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) } NotificationCenter.default.addObserver( @@ -111,13 +109,8 @@ extension TextViewController { self.systemAppearance = newValue.name // Reset content insets and gutter position when appearance changes - if let contentInsets = self.contentInsets { - self.scrollView.contentInsets = contentInsets - if let findViewController = self.findViewController, findViewController.isShowingFindPanel { - self.scrollView.contentInsets.top += FindPanel.height - } - self.gutterView.frame.origin.y = -self.scrollView.contentInsets.top - } + self.styleScrollView() + self.gutterView.frame.origin.y = -self.scrollView.contentInsets.top } } .store(in: &cancellables) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index a5ca4d056..7fa819bbf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -74,6 +74,10 @@ extension TextViewController { } else { scrollView.automaticallyAdjustsContentInsets = true } + + scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 + scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 + scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index b53361492..9337ece4a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -33,8 +33,6 @@ public class TextViewController: NSViewController { var textView: TextView! var gutterView: GutterView! internal var _undoManager: CEUndoManager! - /// Internal reference to any injected layers in the text view. - internal var highlightLayers: [CALayer] = [] internal var systemAppearance: NSAppearance.Name? package var localEvenMonitor: Any? @@ -127,13 +125,24 @@ public class TextViewController: NSViewController { /// The provided highlight provider. public var highlightProviders: [HighlightProviding] - /// Optional insets to offset the text view in the scroll view by. + /// Optional insets to offset the text view and find panel in the scroll view by. public var contentInsets: NSEdgeInsets? { didSet { + styleScrollView() findViewController?.topPadding = contentInsets?.top } } + /// An additional amount to inset text by. Horizontal values are ignored. + /// + /// This value does not affect decorations like the find panel, but affects things that are relative to text, such + /// as line numbers and of course the text itself. + public var additionalTextInsets: NSEdgeInsets? { + didSet { + styleScrollView() + } + } + /// Whether or not text view is editable by user public var isEditable: Bool { didSet { @@ -232,6 +241,7 @@ public class TextViewController: NSViewController { useThemeBackground: Bool, highlightProviders: [HighlightProviding] = [TreeSitterClient()], contentInsets: NSEdgeInsets?, + additionalTextInsets: NSEdgeInsets? = nil, isEditable: Bool, isSelectable: Bool, letterSpacing: Double, @@ -252,6 +262,7 @@ public class TextViewController: NSViewController { self.useThemeBackground = useThemeBackground self.highlightProviders = highlightProviders self.contentInsets = contentInsets + self.additionalTextInsets = additionalTextInsets self.isEditable = isEditable self.isSelectable = isSelectable self.letterSpacing = letterSpacing diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift index 1e4307388..7b0ded2a2 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift @@ -38,9 +38,9 @@ extension FindViewController: FindPanelDelegate { 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: "find") + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) // Re-add the current active emphasis without visual emphasis - if let emphases = target?.emphasisManager?.getEmphases(for: "find"), + if let emphases = target?.emphasisManager?.getEmphases(for: EmphasisGroup.find), let activeEmphasis = emphases.first(where: { !$0.inactive }) { target?.emphasisManager?.addEmphasis( Emphasis( @@ -50,14 +50,14 @@ extension FindViewController: FindPanelDelegate { inactive: false, selectInDocument: true ), - for: "find" + for: EmphasisGroup.find ) } return } // Clear existing emphases before performing new find - target?.emphasisManager?.removeEmphases(for: "find") + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) find(text: text) } @@ -87,7 +87,7 @@ extension FindViewController: FindPanelDelegate { let newActiveRange = findMatches[currentFindMatchIndex] // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: "find") + emphasisManager.removeEmphases(for: EmphasisGroup.find) emphasisManager.addEmphasis( Emphasis( @@ -97,7 +97,7 @@ extension FindViewController: FindPanelDelegate { inactive: false, selectInDocument: true ), - for: "find" + for: EmphasisGroup.find ) return @@ -115,7 +115,7 @@ extension FindViewController: FindPanelDelegate { } // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: "find") + emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) } func findPanelNextButtonClicked() { @@ -150,7 +150,7 @@ extension FindViewController: FindPanelDelegate { let newActiveRange = findMatches[currentFindMatchIndex] // Clear existing emphases before adding the flash - emphasisManager.removeEmphases(for: "find") + emphasisManager.removeEmphases(for: EmphasisGroup.find) emphasisManager.addEmphasis( Emphasis( @@ -160,7 +160,7 @@ extension FindViewController: FindPanelDelegate { inactive: false, selectInDocument: true ), - for: "find" + for: EmphasisGroup.find ) return @@ -178,7 +178,7 @@ extension FindViewController: FindPanelDelegate { } // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: "find") + emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) } func findPanelUpdateMatchCount(_ count: Int) { @@ -186,6 +186,6 @@ extension FindViewController: FindPanelDelegate { } func findPanelClearEmphasis() { - target?.emphasisManager?.removeEmphases(for: "find") + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift index f35eb3d71..d67054f39 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift @@ -57,7 +57,7 @@ extension FindViewController { let emphasisManager = target.emphasisManager else { return } // Clear existing emphases - emphasisManager.removeEmphases(for: "find") + emphasisManager.removeEmphases(for: EmphasisGroup.find) // Create emphasis with the nearest match as active let emphases = findMatches.enumerated().map { index, range in @@ -71,7 +71,7 @@ extension FindViewController { } // Add all emphases - emphasisManager.addEmphases(emphases, for: "find") + emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) } private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 4759e7ec3..4d9172c92 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -14,7 +14,13 @@ final class FindViewController: NSViewController { /// 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? + var topPadding: CGFloat? { + didSet { + if isShowingFindPanel { + setFindPanelConstraintShow() + } + } + } var childView: NSView var findPanel: FindPanel! @@ -100,8 +106,10 @@ final class FindViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() if isShowingFindPanel { // Update constraints for initial state + findPanel.isHidden = false setFindPanelConstraintShow() } else { + findPanel.isHidden = true setFindPanelConstraintHide() } } diff --git a/Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift b/Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift new file mode 100644 index 000000000..37a52b93c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Utils/EmphasisGroup.swift @@ -0,0 +1,11 @@ +// +// EmphasisGroup.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/7/25. +// + +enum EmphasisGroup { + static let brackets = "codeedit.bracketPairs" + static let find = "codeedit.find" +} diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index e2e5544c6..b40e5c566 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -84,7 +84,7 @@ final class TextViewControllerTests: XCTestCase { // MARK: Insets func test_editorInsets() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + let scrollView = try XCTUnwrap(controller.scrollView) scrollView.frame = .init( x: .zero, y: .zero, @@ -131,8 +131,44 @@ final class TextViewControllerTests: XCTestCase { XCTAssertEqual(controller.gutterView.frame.origin.y, -16) } + func test_additionalInsets() throws { + let scrollView = try XCTUnwrap(controller.scrollView) + scrollView.frame = .init( + x: .zero, + y: .zero, + width: 100, + height: 100 + ) + + func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { + XCTAssertEqual(lhs.top, rhs.top) + XCTAssertEqual(lhs.right, rhs.right) + XCTAssertEqual(lhs.bottom, rhs.bottom) + XCTAssertEqual(lhs.left, rhs.left) + } + + controller.contentInsets = nil + controller.additionalTextInsets = nil + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) + XCTAssertEqual(controller.gutterView.frame.origin.y, 0) + + controller.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + controller.additionalTextInsets = NSEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) + + controller.findViewController?.showFindPanel(animated: false) + + // Extra insets do not effect find panel's insets + try assertInsetsEqual( + scrollView.contentInsets, + NSEdgeInsets(top: 10 + FindPanel.height, left: 0, bottom: 10, right: 0) + ) + XCTAssertEqual(controller.findViewController?.findPanelVerticalConstraint.constant, 0) + XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - FindPanel.height) + } + func test_editorOverScroll_ZeroCondition() throws { - let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + let scrollView = try XCTUnwrap(controller.scrollView) scrollView.frame = .zero // editorOverscroll: 0 @@ -213,41 +249,46 @@ final class TextViewControllerTests: XCTestCase { // MARK: Bracket Highlights - func test_bracketHighlights() { + func test_bracketHighlights() throws { + let textView = try XCTUnwrap(controller.textView) + let emphasisManager = try XCTUnwrap(textView.emphasisManager) + func getEmphasisCount() -> Int { emphasisManager.getEmphases(for: EmphasisGroup.brackets).count } + controller.scrollView.setFrameSize(NSSize(width: 500, height: 500)) controller.viewDidLoad() let _ = controller.textView.becomeFirstResponder() controller.bracketPairEmphasis = nil controller.setText("{ Lorem Ipsum {} }") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") + + XCTAssertEqual(getEmphasisCount(), 0, "Controller added bracket emphasis when setting is set to `nil`") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) controller.bracketPairEmphasis = .bordered(color: .black) controller.textView.setNeedsDisplay() controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for bordered.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") controller.bracketPairEmphasis = .underline(color: .black) controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 2, "Controller created an incorrect number of emphases for underline.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") controller.bracketPairEmphasis = .flash controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 1, "Controller created more than one emphasis for flash animation.") controller.setCursorPositions([CursorPosition(line: 1, column: 3)]) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove bracket emphasis.") controller.setCursorPositions([CursorPosition(line: 1, column: 2)]) // After first opening { - XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 1, "Controller created more than one layer for flash animation.") let exp = expectation(description: "Test after 0.8 seconds") let result = XCTWaiter.wait(for: [exp], timeout: 0.8) if result == XCTWaiter.Result.timedOut { - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove layer after flash animation. Expected 0, found \(controller.highlightLayers.count)") + XCTAssertEqual(getEmphasisCount(), 0, "Controller failed to remove emphasis after flash animation.") } else { XCTFail("Delay interrupted") } From d89d6b824f10a81392e49a173593ffd4e3b03bcb Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:05:18 -0500 Subject: [PATCH 36/37] Lint Error --- .../CodeEditSourceEditor/CodeEditSourceEditor.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 8f1de115b..308a7eec6 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -10,6 +10,9 @@ import SwiftUI import CodeEditTextView import CodeEditLanguages +// This type is messy, but it needs *so* many parameters that this is pretty much unavoidable. +// swiftlint:disable type_body_length + /// A SwiftUI View that provides source editing functionality. public struct CodeEditSourceEditor: NSViewControllerRepresentable { package enum TextAPI { @@ -394,3 +397,5 @@ public struct CodeEditTextView: View { EmptyView() } } + +// swiftlint:enable type_body_length From 168d27806cd81ca185e353f7ca216d6da850334a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:06:45 -0500 Subject: [PATCH 37/37] Fixing the lint cause the lint to be mad :-1: --- .../CodeEditSourceEditor/CodeEditSourceEditor.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 308a7eec6..4d0c64e5d 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -11,6 +11,7 @@ import CodeEditTextView import CodeEditLanguages // This type is messy, but it needs *so* many parameters that this is pretty much unavoidable. +// swiftlint:disable file_length // swiftlint:disable type_body_length /// A SwiftUI View that provides source editing functionality. @@ -399,3 +400,4 @@ public struct CodeEditTextView: View { } // swiftlint:enable type_body_length +// swiftlint:enable file_length