diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift index de2783f76..04af69ac7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+Cursor.swift @@ -11,7 +11,7 @@ import AppKit extension TextViewController { /// Sets new cursor positions. /// - Parameter positions: The positions to set. Lines and columns are 1-indexed. - public func setCursorPositions(_ positions: [CursorPosition]) { + public func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool = false) { if isPostingCursorNotification { return } var newSelectedRanges: [NSRange] = [] for position in positions { @@ -33,6 +33,10 @@ extension TextViewController { } } textView.selectionManager.setSelectedRanges(newSelectedRanges) + + if scrollToVisible { + textView.scrollSelectionToVisible() + } } /// Update the ``TextViewController/cursorPositions`` variable with new text selections from the text view. diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 4e5e5782a..3401ea3cf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -5,10 +5,14 @@ // Created by Khan Winter on 3/16/25. // -import Foundation +import AppKit import CodeEditTextView extension TextViewController: FindPanelTarget { + var findPanelTargetView: NSView { + textView + } + func findPanelWillShow(panelHeight: CGFloat) { updateContentInsets() } @@ -17,9 +21,8 @@ extension TextViewController: FindPanelTarget { updateContentInsets() } - func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) { - scrollView.contentInsets.top += mode == .replace ? panelHeight/2 : -panelHeight - gutterView.frame.origin.y = -scrollView.contentInsets.top + func findPanelModeDidChange(to mode: FindPanelMode) { + updateContentInsets() } var emphasisManager: EmphasisManager? { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 15c4f839a..806280d0f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -192,7 +192,7 @@ extension TextViewController { self.findViewController?.showFindPanel() return nil case (0, "\u{1b}"): // Escape key - self.findViewController?.findPanel.dismiss() + self.findViewController?.hideFindPanel() return nil case (_, _): return event diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index a5aaa4acb..e47a43315 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -96,7 +96,11 @@ extension TextViewController { minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 // Inset the top by the find panel height - let findInset = (findViewController?.isShowingFindPanel ?? false) ? findViewController?.panelHeight ?? 0 : 0 + let findInset: CGFloat = if findViewController?.viewModel.isShowingFindPanel ?? false { + findViewController?.viewModel.panelHeight ?? 0 + } else { + 0 + } scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift deleted file mode 100644 index e7cdb8cd6..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// FindPanelDelegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import Foundation - -protocol FindPanelDelegate: AnyObject { - func findPanelOnSubmit() - func findPanelOnDismiss() - func findPanelDidUpdate(_ searchText: String) - func findPanelDidUpdateMode(_ mode: FindPanelMode) - func findPanelDidUpdateWrapAround(_ wrapAround: Bool) - func findPanelDidUpdateMatchCase(_ matchCase: Bool) - func findPanelDidUpdateReplaceText(_ text: String) - func findPanelPrevButtonClicked() - func findPanelNextButtonClicked() - func findPanelReplaceButtonClicked() - func findPanelReplaceAllButtonClicked() - func findPanelUpdateMatchCount(_ count: Int) - func findPanelClearEmphasis() -} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift new file mode 100644 index 000000000..f7bbf26bd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift @@ -0,0 +1,20 @@ +// +// FindPanelMode.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +enum FindPanelMode: CaseIterable { + case find + case replace + + var displayName: String { + switch self { + case .find: + return "Find" + case .replace: + return "Replace" + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index f1857ecb0..90a286715 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -5,18 +5,19 @@ // Created by Khan Winter on 3/10/25. // -import Foundation +import AppKit import CodeEditTextView protocol FindPanelTarget: AnyObject { var emphasisManager: EmphasisManager? { get } var text: String { get } + var findPanelTargetView: NSView { get } var cursorPositions: [CursorPosition] { get } - func setCursorPositions(_ positions: [CursorPosition]) + func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) func updateCursorPosition() func findPanelWillShow(panelHeight: CGFloat) func findPanelWillHide(panelHeight: CGFloat) - func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) + func findPanelModeDidChange(to mode: FindPanelMode) } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift deleted file mode 100644 index dd558f28b..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// FindViewController+Delegate.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController: FindPanelDelegate { - var findPanelMode: FindPanelMode { mode } - var findPanelWrapAround: Bool { wrapAround } - var findPanelMatchCase: Bool { matchCase } - - func findPanelOnSubmit() { - findPanelNextButtonClicked() - } - - func findPanelOnDismiss() { - if isShowingFindPanel { - hideFindPanel() - // Ensure text view becomes first responder after hiding - if let textViewController = target as? TextViewController { - DispatchQueue.main.async { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) - } - } - } - } - - func findPanelDidUpdate(_ text: String) { - // Check if this update was triggered by a return key without shift - if let currentEvent = NSApp.currentEvent, - currentEvent.type == .keyDown, - currentEvent.keyCode == 36, // Return key - !currentEvent.modifierFlags.contains(.shift) { - return // Skip find for regular return key - } - - // Only perform find if we're focusing the text view - if let textViewController = target as? TextViewController, - textViewController.textView.window?.firstResponder === textViewController.textView { - // If the text view has focus, just clear visual emphases but keep matches in memory - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - // Re-add the current active emphasis without visual emphasis - if let emphases = target?.emphasisManager?.getEmphases(for: EmphasisGroup.find), - let activeEmphasis = emphases.first(where: { !$0.inactive }) { - target?.emphasisManager?.addEmphasis( - Emphasis( - range: activeEmphasis.range, - style: .standard, - flash: false, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - } - return - } - - // Clear existing emphases before performing new find - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - find(text: text) - } - - func findPanelDidUpdateMode(_ mode: FindPanelMode) { - self.mode = mode - if isShowingFindPanel { - target?.findPanelModeDidChange(to: mode, panelHeight: panelHeight) - } - } - - func findPanelDidUpdateWrapAround(_ wrapAround: Bool) { - self.wrapAround = wrapAround - } - - func findPanelDidUpdateMatchCase(_ matchCase: Bool) { - self.matchCase = matchCase - if !findText.isEmpty { - performFind() - addEmphases() - } - } - - func findPanelDidUpdateReplaceText(_ text: String) { - self.replaceText = text - } - - private func flashCurrentMatch(emphasisManager: EmphasisManager, textViewController: TextViewController) { - let newActiveRange = findMatches[currentFindMatchIndex] - emphasisManager.removeEmphases(for: EmphasisGroup.find) - emphasisManager.addEmphasis( - Emphasis( - range: newActiveRange, - style: .standard, - flash: true, - inactive: false, - selectInDocument: true - ), - for: EmphasisGroup.find - ) - } - - func findPanelPrevButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.up.to.line", - over: textViewController.textView - ) - return - } - - // Check if we're at the first match and wrapAround is false - if !wrapAround && currentFindMatchIndex == 0 { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.up.to.line", - over: textViewController.textView - ) - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - return - } - - // Update to previous match - currentFindMatchIndex = (currentFindMatchIndex - 1 + findMatches.count) % findMatches.count - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - } - - private func updateEmphasesForCurrentMatch(emphasisManager: EmphasisManager, flash: Bool = false) { - // Create updated emphases with current match emphasized - let updatedEmphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: flash, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Replace all emphases to update state - emphasisManager.replaceEmphases(updatedEmphases, for: EmphasisGroup.find) - } - - func findPanelNextButtonClicked() { - guard let textViewController = target as? TextViewController, - let emphasisManager = target?.emphasisManager else { return } - - // Check if there are any matches - if findMatches.isEmpty { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - return - } - - // Check if we're at the last match and wrapAround is false - if !wrapAround && currentFindMatchIndex == findMatches.count - 1 { - NSSound.beep() - BezelNotification.show( - symbolName: "arrow.down.to.line", - over: textViewController.textView - ) - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - return - } - - // Update to next match - currentFindMatchIndex = (currentFindMatchIndex + 1) % findMatches.count - - // If the text view has focus, show a flash animation for the current match - if textViewController.textView.window?.firstResponder === textViewController.textView { - flashCurrentMatch(emphasisManager: emphasisManager, textViewController: textViewController) - return - } - - updateEmphasesForCurrentMatch(emphasisManager: emphasisManager) - } - - func findPanelReplaceButtonClicked() { - guard !findMatches.isEmpty else { return } - replaceCurrentMatch() - } - - func findPanelReplaceAllButtonClicked() { - guard !findMatches.isEmpty else { return } - replaceAllMatches() - } - - func findPanelUpdateMatchCount(_ count: Int) { - findPanel.updateMatchCount(count) - } - - func findPanelClearEmphasis() { - target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift deleted file mode 100644 index 7d6eda560..000000000 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift +++ /dev/null @@ -1,203 +0,0 @@ -// -// FindViewController+Operations.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 4/3/25. -// - -import AppKit -import CodeEditTextView - -extension FindViewController { - func find(text: String) { - findText = text - performFind() - addEmphases() - } - - func performFind() { - // Don't find if target or emphasisManager isn't ready - guard let target = target else { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - // Clear emphases and return if query is empty - if findText.isEmpty { - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - findMatches = [] - currentFindMatchIndex = 0 - return - } - - // Set case sensitivity based on matchCase property - let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] - let escapedQuery = NSRegularExpression.escapedPattern(for: findText) - - guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { - 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 - } - - func replaceCurrentMatch() { - guard let target = target, - !findMatches.isEmpty else { return } - - // Get the current match range - let currentMatchRange = findMatches[currentFindMatchIndex] - - // Set cursor positions to the match range - target.setCursorPositions([CursorPosition(range: currentMatchRange)]) - - // Replace the text using the cursor positions - if let textViewController = target as? TextViewController { - textViewController.textView.insertText(replaceText, replacementRange: currentMatchRange) - } - - // Adjust the length of the replacement - let lengthDiff = replaceText.utf16.count - currentMatchRange.length - - // Update the current match index - if findMatches.isEmpty { - currentFindMatchIndex = 0 - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - } else { - // Update all match ranges after the current match - for index in (currentFindMatchIndex + 1).. $1.location } - - // Begin undo grouping using CEUndoManager - if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { - ceUndoManager.beginUndoGrouping() - } - - // Replace each match - for matchRange in sortedMatches { - // Set cursor positions to the match range - target.setCursorPositions([CursorPosition(range: matchRange)]) - - // Replace the text using the cursor positions - textViewController.textView.insertText(replaceText, replacementRange: matchRange) - } - - // End undo grouping - if let ceUndoManager = textViewController.textView.undoManager as? CEUndoManager.DelegatedUndoManager { - ceUndoManager.endUndoGrouping() - } - - // Set cursor position to the end of the last replaced match - if let lastMatch = sortedMatches.first { - let endPosition = lastMatch.location + replaceText.utf16.count - let cursorRange = NSRange(location: endPosition, length: 0) - target.setCursorPositions([CursorPosition(range: cursorRange)]) - textViewController.textView.selectionManager.setSelectedRanges([cursorRange]) - textViewController.textView.scrollSelectionToVisible() - textViewController.textView.needsDisplay = true - } - - // Clear all matches since they've been replaced - findMatches = [] - currentFindMatchIndex = 0 - findPanel.findDelegate?.findPanelUpdateMatchCount(0) - - // Update the emphases - addEmphases() - } - - func addEmphases() { - guard let target = target, - let emphasisManager = target.emphasisManager else { return } - - // Clear existing emphases - emphasisManager.removeEmphases(for: EmphasisGroup.find) - - // Create emphasis with the nearest match as active - let emphases = findMatches.enumerated().map { index, range in - Emphasis( - range: range, - style: .standard, - flash: false, - inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex - ) - } - - // Add all emphases - emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) - } - - private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { - // order the array as follows - // Found: 1 -> 2 -> 3 -> 4 - // Cursor: | - // Result: 3 -> 4 -> 1 -> 2 - guard let cursorPosition = target?.cursorPositions.first else { return nil } - let start = cursorPosition.range.location - - var left = 0 - var right = matchRanges.count - 1 - var bestIndex = -1 - var bestDiff = Int.max // Stores the closest difference - - while left <= right { - let mid = left + (right - left) / 2 - let midStart = matchRanges[mid].location - let diff = abs(midStart - start) - - // If it's an exact match, return immediately - if diff == 0 { - return mid - } - - // If this is the closest so far, update the best index - if diff < bestDiff { - bestDiff = diff - bestIndex = mid - } - - // Move left or right based on the cursor position - if midStart < start { - left = mid + 1 - } else { - right = mid - 1 - } - } - - return bestIndex >= 0 ? bestIndex : nil - } - - // Only re-find the part of the file that changed upwards - private func reFind() { } -} diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 2886bfc2e..bfea53c92 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -16,22 +16,21 @@ extension FindViewController { /// - Animates the find panel into position (resolvedTopPadding). /// - Makes the find panel the first responder. func showFindPanel(animated: Bool = true) { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { // If panel is already showing, just focus the text field - _ = findPanel?.becomeFirstResponder() + viewModel.isFocused = true return } - if mode == .replace { - mode = .find - findPanel.updateMode(mode) + if viewModel.mode == .replace { + viewModel.mode = .find } - isShowingFindPanel = true + viewModel.isShowingFindPanel = true // Smooth out the animation by placing the find panel just outside the correct position before animating. findPanel.isHidden = false - findPanelVerticalConstraint.constant = resolvedTopPadding - panelHeight + findPanelVerticalConstraint.constant = resolvedTopPadding - viewModel.panelHeight view.layoutSubtreeIfNeeded() @@ -39,12 +38,12 @@ extension FindViewController { 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: panelHeight) + viewModel.target?.findPanelWillShow(panelHeight: viewModel.panelHeight) setFindPanelConstraintShow() } onComplete: { } - _ = findPanel?.becomeFirstResponder() - findPanel?.addEventMonitor() + viewModel.isFocused = true + findPanel.addEventMonitor() } /// Hide the find panel @@ -55,20 +54,21 @@ extension FindViewController { /// - Hides the find panel. /// - Sets the text view to be the first responder. func hideFindPanel(animated: Bool = true) { - isShowingFindPanel = false - _ = findPanel?.resignFirstResponder() - findPanel?.removeEventMonitor() + viewModel.isShowingFindPanel = false + _ = findPanel.resignFirstResponder() + findPanel.removeEventMonitor() conditionalAnimated(animated) { - target?.findPanelWillHide(panelHeight: panelHeight) + viewModel.target?.findPanelWillHide(panelHeight: viewModel.panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in self?.findPanel.isHidden = true + self?.viewModel.isFocused = false } // Set first responder back to text view - if let textViewController = target as? TextViewController { - _ = textViewController.textView.window?.makeFirstResponder(textViewController.textView) + if let target = viewModel.target { + _ = target.findPanelTargetView.window?.makeFirstResponder(target.findPanelTargetView) } } @@ -119,7 +119,7 @@ extension FindViewController { // SwiftUI hates us. It refuses to move views outside of the safe are if they don't have the `.ignoresSafeArea` // modifier, but with that modifier on it refuses to allow it to be animated outside the safe area. // The only way I found to fix it was to multiply the height by 3 here. - findPanelVerticalConstraint.constant = resolvedTopPadding - (panelHeight * 3) + findPanelVerticalConstraint.constant = resolvedTopPadding - (viewModel.panelHeight * 3) findPanelVerticalConstraint.isActive = true } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 1e2d2f05d..a9e2dd3b0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -10,69 +10,35 @@ import CodeEditTextView /// Creates a container controller for displaying and hiding a find panel with a content view. final class FindViewController: NSViewController { - weak var target: FindPanelTarget? + var viewModel: FindPanelViewModel /// The amount of padding from the top of the view to inset the find panel by. /// When set, the safe area is ignored, and the top padding is measured from the top of the view's frame. var topPadding: CGFloat? { didSet { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { setFindPanelConstraintShow() } } } var childView: NSView - var findPanel: FindPanel! - var findMatches: [NSRange] = [] - - // TODO: we might make this nil if no current match so we can disable the match button in the find panel - var currentFindMatchIndex: Int = 0 - var findText: String = "" - var replaceText: String = "" - var matchCase: Bool = false - var wrapAround: Bool = true - var mode: FindPanelMode = .find + var findPanel: FindPanelHostingView var findPanelVerticalConstraint: NSLayoutConstraint! - var isShowingFindPanel: Bool = false - /// The 'real' top padding amount. /// Is equal to ``topPadding`` if set, or the view's top safe area inset if not. var resolvedTopPadding: CGFloat { (topPadding ?? view.safeAreaInsets.top) } - /// The height of the find panel. - var panelHeight: CGFloat { - return self.mode == .replace ? 56 : 28 - } - init(target: FindPanelTarget, childView: NSView) { - self.target = target + viewModel = FindPanelViewModel(target: target) self.childView = childView + findPanel = FindPanelHostingView(viewModel: viewModel) super.init(nibName: nil, bundle: nil) - self.findPanel = FindPanel(delegate: self, textView: target as? NSView) - - // Add notification observer for text changes - if let textViewController = target as? TextViewController { - NotificationCenter.default.addObserver( - self, - selector: #selector(textDidChange), - name: TextView.textDidChangeNotification, - object: textViewController.textView - ) - } - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc private func textDidChange() { - // Only update if we have find text - if !findText.isEmpty { - performFind() + viewModel.dismiss = { [weak self] in + self?.hideFindPanel() } } @@ -115,7 +81,7 @@ final class FindViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() - if isShowingFindPanel { // Update constraints for initial state + if viewModel.isShowingFindPanel { // Update constraints for initial state findPanel.isHidden = false setFindPanelConstraintShow() } else { diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift index c0993d603..8245681aa 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindModePicker.swift @@ -11,8 +11,6 @@ struct FindModePicker: NSViewRepresentable { @Binding var mode: FindPanelMode @Binding var wrapAround: Bool @Environment(\.controlActiveState) var activeState - let onToggleWrapAround: () -> Void - let onModeChange: () -> Void private func createSymbolButton(context: Context) -> NSButton { let button = NSButton(frame: .zero) @@ -129,7 +127,7 @@ struct FindModePicker: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(self) + Coordinator(mode: $mode, wrapAround: $wrapAround) } var body: some View { @@ -143,10 +141,12 @@ struct FindModePicker: NSViewRepresentable { } class Coordinator: NSObject { - let parent: FindModePicker + @Binding var mode: FindPanelMode + @Binding var wrapAround: Bool - init(_ parent: FindModePicker) { - self.parent = parent + init(mode: Binding, wrapAround: Binding) { + self._mode = mode + self._wrapAround = wrapAround } @objc func openMenu(_ sender: NSButton) { @@ -156,12 +156,11 @@ struct FindModePicker: NSViewRepresentable { } @objc func modeSelected(_ sender: NSMenuItem) { - parent.mode = sender.tag == 0 ? .find : .replace - parent.onModeChange() + mode = sender.tag == 0 ? .find : .replace } @objc func toggleWrapAround(_ sender: NSMenuItem) { - parent.onToggleWrapAround() + wrapAround.toggle() } } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift deleted file mode 100644 index 1d9dd8b78..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// FindPanel.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 3/10/25. -// - -import SwiftUI -import AppKit -import Combine - -// NSView wrapper for using SwiftUI view in AppKit -final class FindPanel: NSView { - weak var findDelegate: FindPanelDelegate? - private var hostingView: NSHostingView! - private var viewModel: FindPanelViewModel! - private weak var textView: NSView? - private var isViewReady = false - private var findQueryText: String = "" // Store search text at panel level - private var eventMonitor: Any? - - init(delegate: FindPanelDelegate?, textView: NSView?) { - self.findDelegate = delegate - self.textView = textView - super.init(frame: .zero) - - viewModel = FindPanelViewModel(delegate: findDelegate) - viewModel.findText = findQueryText // Initialize with stored value - hostingView = NSHostingView(rootView: FindPanelView(viewModel: viewModel)) - hostingView.translatesAutoresizingMaskIntoConstraints = false - - // Make the NSHostingView transparent - hostingView.wantsLayer = true - hostingView.layer?.backgroundColor = .clear - - // Make the FindPanel itself transparent - self.wantsLayer = true - self.layer?.backgroundColor = .clear - - addSubview(hostingView) - - NSLayoutConstraint.activate([ - hostingView.topAnchor.constraint(equalTo: topAnchor), - hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), - hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - - self.translatesAutoresizingMaskIntoConstraints = false - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - if !isViewReady && superview != nil { - isViewReady = true - viewModel.startObservingFindText() - } - } - - deinit { - removeEventMonitor() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var fittingSize: NSSize { - hostingView.fittingSize - } - - // MARK: - First Responder Management - - override func becomeFirstResponder() -> Bool { - viewModel.setFocus(true) - return true - } - - override func resignFirstResponder() -> Bool { - viewModel.setFocus(false) - return true - } - - // MARK: - Event Monitor Management - - func addEventMonitor() { - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in - if event.keyCode == 53 { // if esc pressed - self.dismiss() - return nil // do not play "beep" sound - } - return event - } - } - - func removeEventMonitor() { - if let monitor = eventMonitor { - NSEvent.removeMonitor(monitor) - eventMonitor = nil - } - } - - // MARK: - Public Methods - - func dismiss() { - viewModel.onDismiss() - } - - func updateMatchCount(_ count: Int) { - viewModel.updateMatchCount(count) - } - - func updateMode(_ mode: FindPanelMode) { - viewModel.mode = mode - } - - // MARK: - Search Text Management - - func updateSearchText(_ text: String) { - findQueryText = text - viewModel.findText = text - findDelegate?.findPanelDidUpdate(text) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift new file mode 100644 index 000000000..a02e4f7c6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -0,0 +1,60 @@ +// +// FindPanelHostingView.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 FindPanelHostingView: NSHostingView { + private weak var viewModel: FindPanelViewModel? + + private var eventMonitor: Any? + + init(viewModel: FindPanelViewModel) { + self.viewModel = viewModel + super.init(rootView: FindPanelView(viewModel: viewModel)) + + self.translatesAutoresizingMaskIntoConstraints = false + + self.wantsLayer = true + self.layer?.backgroundColor = .clear + + self.translatesAutoresizingMaskIntoConstraints = false + } + + @MainActor @preconcurrency required init(rootView: FindPanelView) { + super.init(rootView: rootView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + removeEventMonitor() + } + + // MARK: - Event Monitor Management + + func addEventMonitor() { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in + if event.keyCode == 53 { // if esc pressed + self.viewModel?.dismiss?() + return nil // do not play "beep" sound + } + return event + } + } + + func removeEventMonitor() { + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index edf115f9e..7b8fb551e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -10,184 +10,133 @@ import AppKit import CodeEditSymbols struct FindPanelView: View { + enum FindPanelFocus: Equatable { + case find + case replace + } + @Environment(\.controlActiveState) var activeState @ObservedObject var viewModel: FindPanelViewModel - @FocusState private var isFindFieldFocused: Bool - @FocusState private var isReplaceFieldFocused: Bool + @State private var findModePickerWidth: CGFloat = 1.0 + + @FocusState private var focus: FindPanelFocus? var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 5) { - PanelTextField( - "Text", - text: $viewModel.findText, - leadingAccessories: { - FindModePicker( - mode: $viewModel.mode, - wrapAround: $viewModel.wrapAround, - onToggleWrapAround: viewModel.toggleWrapAround, - onModeChange: { - isFindFieldFocused = true - if let textField = NSApp.keyWindow?.firstResponder as? NSTextView { - textField.selectAll(nil) - } - } - ) - .background(GeometryReader { geometry in - Color.clear.onAppear { - viewModel.findModePickerWidth = geometry.size.width - } - .onChange(of: geometry.size.width) { newWidth in - viewModel.findModePickerWidth = newWidth - } - }) - Divider() - }, - trailingAccessories: { - Divider() - Toggle(isOn: $viewModel.matchCase, label: { - Image(systemName: "textformat") - .font(.system( - size: 11, - weight: viewModel.matchCase ? .bold : .medium - )) - .foregroundStyle( - Color(nsColor: viewModel.matchCase - ? .controlAccentColor - : .labelColor - ) - ) - .frame(width: 30, height: 20) - }) - .toggleStyle(.icon) - }, - helperText: viewModel.findText.isEmpty - ? nil - : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", - clearable: true - ) - .controlSize(.small) - .focused($isFindFieldFocused) - .onChange(of: isFindFieldFocused) { newValue in - viewModel.setFocus(newValue || isReplaceFieldFocused) - } - .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.onDismiss) { - Text("Done") - .padding(.horizontal, 5) - } - .buttonStyle(PanelButtonStyle()) + HStack(spacing: 5) { + VStack(alignment: .leading, spacing: 4) { + FindSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) + if viewModel.mode == .replace { + ReplaceSearchField(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) } - .background(GeometryReader { geometry in - Color.clear.onAppear { - viewModel.findControlsWidth = geometry.size.width - } - .onChange(of: geometry.size.width) { newWidth in - viewModel.findControlsWidth = newWidth - } - }) } - .padding(.horizontal, 5) - if viewModel.mode == .replace { - HStack(spacing: 5) { - PanelTextField( - "Text", - text: $viewModel.replaceText, - leadingAccessories: { - HStack(spacing: 0) { - Image(systemName: "pencil") - .foregroundStyle(.secondary) - .padding(.leading, 8) - .padding(.trailing, 5) - Text("With") - } - .frame(width: viewModel.findModePickerWidth, alignment: .leading) - Divider() - }, - clearable: true - ) - .controlSize(.small) - .focused($isReplaceFieldFocused) - .onChange(of: isReplaceFieldFocused) { newValue in - viewModel.setFocus(newValue || isFindFieldFocused) - } - HStack(spacing: 4) { - ControlGroup { - Button(action: viewModel.replaceButtonClicked) { - Text("Replace") - .opacity( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 ? 0.33 : 1 - ) - .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } - // TODO: disable if there is not an active match - .disabled( - !viewModel.isFocused - || viewModel.findText.isEmpty - || viewModel.matchCount == 0 - ) - Divider() - .overlay(Color(nsColor: .tertiaryLabelColor)) - Button(action: viewModel.replaceAllButtonClicked) { - Text("All") - .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) - .frame(width: viewModel.findControlsWidth/2 - 12 - 0.5) - } - .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) - } - .controlGroupStyle(PanelControlGroupStyle()) - .fixedSize() - } + VStack(alignment: .leading, spacing: 4) { + doneNextControls + if viewModel.mode == .replace { + Spacer(minLength: 0) + replaceControls } - .padding(.horizontal, 5) } + .fixedSize() } + .padding(.horizontal, 5) .frame(height: viewModel.panelHeight) .background(.bar) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) + .onChange(of: focus) { newValue in + viewModel.isFocused = newValue != nil } - .onChange(of: viewModel.replaceText) { newValue in - viewModel.onReplaceTextChange(newValue) + .onChange(of: viewModel.findText) { _ in + viewModel.findTextDidChange() } - .onChange(of: viewModel.mode) { newMode in - viewModel.onModeChange(newMode) + .onChange(of: viewModel.wrapAround) { _ in + viewModel.find() } - .onChange(of: viewModel.wrapAround) { newValue in - viewModel.onWrapAroundChange(newValue) - } - .onChange(of: viewModel.matchCase) { newValue in - viewModel.onMatchCaseChange(newValue) + .onChange(of: viewModel.matchCase) { _ in + viewModel.find() } .onChange(of: viewModel.isFocused) { newValue in - isFindFieldFocused = newValue - if !newValue { - viewModel.removeEmphasis() + if newValue { + if focus == nil { + focus = .find + } + if !viewModel.findText.isEmpty { + // Restore emphases when focus is regained and we have search text + viewModel.addMatchEmphases(flashCurrent: false) + } + } else { + viewModel.clearMatchEmphases() + } + } + } + + @ViewBuilder private var doneNextControls: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.moveToPreviousMatch() + } label: { + Image(systemName: "chevron.left") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + Divider() + .overlay(Color(nsColor: .tertiaryLabelColor)) + Button { + viewModel.moveToNextMatch() + } label: { + Image(systemName: "chevron.right") + .opacity(viewModel.matchCount == 0 ? 0.33 : 1) + .padding(.horizontal, 5) + } + .disabled(viewModel.matchCount == 0) + } + .controlGroupStyle(PanelControlGroupStyle()) + .fixedSize() + Button { + viewModel.dismiss?() + } label: { + Text("Done") + .padding(.horizontal, 5) + } + .buttonStyle(PanelButtonStyle()) + } + } + + @ViewBuilder private var replaceControls: some View { + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.replace(all: false) + } label: { + Text("Replace") + .opacity( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 ? 0.33 : 1 + ) + } + // TODO: disable if there is not an active match + .disabled( + !viewModel.isFocused + || viewModel.findText.isEmpty + || viewModel.matchCount == 0 + ) + .frame(maxWidth: .infinity) + + Divider().overlay(Color(nsColor: .tertiaryLabelColor)) + + Button { + viewModel.replace(all: true) + } label: { + Text("All") + .opacity(viewModel.findText.isEmpty || viewModel.matchCount == 0 ? 0.33 : 1) + } + .disabled(viewModel.findText.isEmpty || viewModel.matchCount == 0) + .frame(maxWidth: .infinity) } + .controlGroupStyle(PanelControlGroupStyle()) } + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift deleted file mode 100644 index fdc55b716..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// FindPanelViewModel.swift -// CodeEditSourceEditor -// -// Created by Austin Condiff on 3/12/25. -// - -import SwiftUI -import Combine - -enum FindPanelMode: CaseIterable { - case find - case replace - - var displayName: String { - switch self { - case .find: - return "Find" - case .replace: - return "Replace" - } - } -} - -class FindPanelViewModel: ObservableObject { - @Published var findText: String = "" - @Published var replaceText: String = "" - @Published var mode: FindPanelMode = .find - @Published var wrapAround: Bool = true - @Published var matchCount: Int = 0 - @Published var isFocused: Bool = false - @Published var findModePickerWidth: CGFloat = 0 - @Published var findControlsWidth: CGFloat = 0 - @Published var matchCase: Bool = false - - var panelHeight: CGFloat { - return mode == .replace ? 56 : 28 - } - - private weak var delegate: FindPanelDelegate? - - init(delegate: FindPanelDelegate?) { - self.delegate = delegate - } - - func startObservingFindText() { - if !findText.isEmpty { - delegate?.findPanelDidUpdate(findText) - } - } - - func onFindTextChange(_ text: String) { - delegate?.findPanelDidUpdate(text) - } - - func onReplaceTextChange(_ text: String) { - delegate?.findPanelDidUpdateReplaceText(text) - } - - func onModeChange(_ mode: FindPanelMode) { - delegate?.findPanelDidUpdateMode(mode) - } - - func onWrapAroundChange(_ wrapAround: Bool) { - delegate?.findPanelDidUpdateWrapAround(wrapAround) - } - - func onMatchCaseChange(_ matchCase: Bool) { - delegate?.findPanelDidUpdateMatchCase(matchCase) - } - - func onSubmit() { - delegate?.findPanelOnSubmit() - } - - func onDismiss() { - delegate?.findPanelOnDismiss() - } - - func setFocus(_ focused: Bool) { - isFocused = focused - if focused && !findText.isEmpty { - // Restore emphases when focus is regained and we have search text - delegate?.findPanelDidUpdate(findText) - } - } - - func updateMatchCount(_ count: Int) { - matchCount = count - } - - func removeEmphasis() { - delegate?.findPanelClearEmphasis() - } - - func prevButtonClicked() { - delegate?.findPanelPrevButtonClicked() - } - - func nextButtonClicked() { - delegate?.findPanelNextButtonClicked() - } - - func replaceButtonClicked() { - delegate?.findPanelReplaceButtonClicked() - } - - func replaceAllButtonClicked() { - delegate?.findPanelReplaceAllButtonClicked() - } - - func toggleWrapAround() { - wrapAround.toggle() - delegate?.findPanelDidUpdateWrapAround(wrapAround) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift new file mode 100644 index 000000000..00a528ee2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift @@ -0,0 +1,64 @@ +// +// FindSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct FindSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.findText, + leadingAccessories: { + FindModePicker( + mode: $viewModel.mode, + wrapAround: $viewModel.wrapAround + ) + .background(GeometryReader { geometry in + Color.clear.onAppear { + findModePickerWidth = geometry.size.width + } + .onChange(of: geometry.size.width) { newWidth in + findModePickerWidth = newWidth + } + }) + .focusable(false) + Divider() + }, + trailingAccessories: { + Divider() + Toggle(isOn: $viewModel.matchCase, label: { + Image(systemName: "textformat") + .font(.system( + size: 11, + weight: viewModel.matchCase ? .bold : .medium + )) + .foregroundStyle( + Color(nsColor: viewModel.matchCase + ? .controlAccentColor + : .labelColor + ) + ) + .frame(width: 30, height: 20) + }) + .toggleStyle(.icon) + }, + helperText: viewModel.findText.isEmpty + ? nil + : "\(viewModel.matchCount) \(viewModel.matchCount == 1 ? "match" : "matches")", + clearable: true + ) + .controlSize(.small) + .focused($focus, equals: .find) + .onSubmit { + viewModel.moveToNextMatch() + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift new file mode 100644 index 000000000..9e40721c6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift @@ -0,0 +1,35 @@ +// +// ReplaceSearchField.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct ReplaceSearchField: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + PanelTextField( + "Text", + text: $viewModel.replaceText, + leadingAccessories: { + HStack(spacing: 0) { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .padding(.leading, 8) + .padding(.trailing, 5) + Text("With") + } + .frame(width: findModePickerWidth, alignment: .leading) + Divider() + }, + clearable: true + ) + .controlSize(.small) + .focused($focus, equals: .replace) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift new file mode 100644 index 000000000..74d411e45 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift @@ -0,0 +1,37 @@ +// +// FindPanelViewModel+Emphasis.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import CodeEditTextView + +extension FindPanelViewModel { + func addMatchEmphases(flashCurrent: Bool) { + guard let target = target, let emphasisManager = target.emphasisManager else { + return + } + + // Clear existing emphases + emphasisManager.removeEmphases(for: EmphasisGroup.find) + + // Create emphasis with the nearest match as active + let emphases = findMatches.enumerated().map { index, range in + Emphasis( + range: range, + style: .standard, + flash: flashCurrent && index == currentFindMatchIndex, + inactive: index != currentFindMatchIndex, + selectInDocument: index == currentFindMatchIndex + ) + } + + // Add all emphases + emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) + } + + func clearMatchEmphases() { + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift new file mode 100644 index 000000000..438df586a --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -0,0 +1,83 @@ +// +// FindPanelViewModel+Find.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation + +extension FindPanelViewModel { + // MARK: - Find + + /// Performs a find operation on the find target and updates both the ``findMatches`` array and the emphasis + /// manager's emphases. + func find() { + // Don't find if target or emphasisManager isn't ready or the query is empty + guard let target = target, isFocused, !findText.isEmpty else { + updateMatches([]) + return + } + + // Set case sensitivity based on matchCase property + let findOptions: NSRegularExpression.Options = matchCase ? [] : [.caseInsensitive] + let escapedQuery = NSRegularExpression.escapedPattern(for: findText) + + guard let regex = try? NSRegularExpression(pattern: escapedQuery, options: findOptions) else { + updateMatches([]) + return + } + + let text = target.text + let matches = regex.matches(in: text, range: NSRange(location: 0, length: text.utf16.count)) + + updateMatches(matches.map(\.range)) + + // Find the nearest match to the current cursor position + currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) ?? 0 + addMatchEmphases(flashCurrent: false) + } + + // MARK: - Get Nearest Emphasis Index + + private func getNearestEmphasisIndex(matchRanges: [NSRange]) -> Int? { + // order the array as follows + // Found: 1 -> 2 -> 3 -> 4 + // Cursor: | + // Result: 3 -> 4 -> 1 -> 2 + guard let cursorPosition = target?.cursorPositions.first else { return nil } + let start = cursorPosition.range.location + + var left = 0 + var right = matchRanges.count - 1 + var bestIndex = -1 + var bestDiff = Int.max // Stores the closest difference + + while left <= right { + let mid = left + (right - left) / 2 + let midStart = matchRanges[mid].location + let diff = abs(midStart - start) + + // If it's an exact match, return immediately + if diff == 0 { + return mid + } + + // If this is the closest so far, update the best index + if diff < bestDiff { + bestDiff = diff + bestIndex = mid + } + + // Move left or right based on the cursor position + if midStart < start { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return bestIndex >= 0 ? bestIndex : nil + } + +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift new file mode 100644 index 000000000..66b0d6b2b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -0,0 +1,64 @@ +// +// FindPanelViewModel+Move.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import AppKit + +extension FindPanelViewModel { + func moveToNextMatch() { + moveMatch(forwards: true) + } + + func moveToPreviousMatch() { + moveMatch(forwards: false) + } + + private func moveMatch(forwards: Bool) { + guard let target = target else { return } + + guard !findMatches.isEmpty else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + // From here on out we want to emphasize the result no matter what + defer { addMatchEmphases(flashCurrent: isTargetFirstResponder) } + + guard let currentFindMatchIndex else { + self.currentFindMatchIndex = 0 + return + } + + let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 + guard !isAtLimit || wrapAround else { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + self.currentFindMatchIndex = if forwards { + (currentFindMatchIndex + 1) % findMatches.count + } else { + (currentFindMatchIndex - 1 + (findMatches.count)) % findMatches.count + } + if isAtLimit { + showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) + } + } + + private func showWrapNotification(forwards: Bool, error: Bool, targetView: NSView) { + if error { + NSSound.beep() + } + BezelNotification.show( + symbolName: error ? + forwards ? "arrow.up.to.line" : "arrow.down.to.line" + : forwards + ? "arrow.trianglehead.topright.capsulepath.clockwise" + : "arrow.trianglehead.bottomleft.capsulepath.clockwise", + over: targetView + ) + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift new file mode 100644 index 000000000..278765b1f --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -0,0 +1,69 @@ +// +// FindPanelViewModel+Replace.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation +import CodeEditTextView + +extension FindPanelViewModel { + /// Replace one or all ``findMatches`` with the contents of ``replaceText``. + /// - Parameter all: If true, replaces all matches instead of just the selected one. + func replace(all: Bool) { + guard let target = target, + let currentFindMatchIndex, + !findMatches.isEmpty, + let textViewController = target as? TextViewController else { + return + } + + if all { + textViewController.textView.undoManager?.beginUndoGrouping() + textViewController.textView.textStorage.beginEditing() + + var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) + for (idx, _) in sortedMatches.enumerated().reversed() { + replaceMatch(index: idx, textView: textViewController.textView, matches: &sortedMatches) + } + + textViewController.textView.textStorage.endEditing() + textViewController.textView.undoManager?.endUndoGrouping() + + if let lastMatch = sortedMatches.last { + target.setCursorPositions( + [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], + scrollToVisible: true + ) + } + + updateMatches([]) + } else { + replaceMatch(index: currentFindMatchIndex, textView: textViewController.textView, matches: &findMatches) + updateMatches(findMatches) + } + + // Update the emphases + addMatchEmphases(flashCurrent: true) + } + + /// Replace a single match in the text view, updating all other find matches with any length changes. + /// - Parameters: + /// - index: The index of the match to replace in the `matches` array. + /// - textView: The text view to replace characters in. + /// - matches: The array of matches to use and update. + private func replaceMatch(index: Int, textView: TextView, matches: inout [NSRange]) { + let range = matches[index] + // Set cursor positions to the match range + textView.replaceCharacters(in: range, with: replaceText) + + // Adjust the length of the replacement + let lengthDiff = replaceText.utf16.count - range.length + + // Update all match ranges after the current match + for idx in matches.dropFirst(index + 1).indices { + matches[idx].location -= lengthDiff + } + } +} diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift new file mode 100644 index 000000000..19f62018b --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -0,0 +1,99 @@ +// +// FindPanelViewModel.swift +// CodeEditSourceEditor +// +// Created by Austin Condiff on 3/12/25. +// + +import SwiftUI +import Combine +import CodeEditTextView + +class FindPanelViewModel: ObservableObject { + weak var target: FindPanelTarget? + var dismiss: (() -> Void)? + + @Published var findMatches: [NSRange] = [] + @Published var currentFindMatchIndex: Int? + @Published var isShowingFindPanel: Bool = false + + @Published var findText: String = "" + @Published var replaceText: String = "" + @Published var mode: FindPanelMode = .find { + didSet { + self.target?.findPanelModeDidChange(to: mode) + } + } + + @Published var isFocused: Bool = false + + @Published var matchCase: Bool = false + @Published var wrapAround: Bool = true + + /// The height of the find panel. + var panelHeight: CGFloat { + return mode == .replace ? 54 : 28 + } + + /// The number of current find matches. + var matchCount: Int { + findMatches.count + } + + var isTargetFirstResponder: Bool { + target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView + } + + init(target: FindPanelTarget) { + self.target = target + + // Add notification observer for text changes + if let textViewController = target as? TextViewController { + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange), + name: TextView.textDidChangeNotification, + object: textViewController.textView + ) + } + } + + // MARK: - Update Matches + + func updateMatches(_ newMatches: [NSRange]) { + findMatches = newMatches + currentFindMatchIndex = newMatches.isEmpty ? nil : 0 + } + + // MARK: - Text Listeners + + /// Find target's text content changed, we need to re-search the contents and emphasize results. + @objc private func textDidChange() { + // Only update if we have find text + if !findText.isEmpty { + find() + } + } + + /// The contents of the find search field changed, trigger related events. + func findTextDidChange() { + // Check if this update was triggered by a return key without shift + if let currentEvent = NSApp.currentEvent, + currentEvent.type == .keyDown, + currentEvent.keyCode == 36, // Return key + !currentEvent.modifierFlags.contains(.shift) { + return // Skip find for regular return key + } + + // If the textview is first responder, exit fast + if target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView { + // If the text view has focus, just clear visual emphases but keep our find matches + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + return + } + + // Clear existing emphases before performing new find + target?.emphasisManager?.removeEmphases(for: EmphasisGroup.find) + find() + } +} diff --git a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift index cda97dbca..9c66afc67 100644 --- a/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift +++ b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift @@ -33,8 +33,6 @@ struct PanelTextField: View var onClear: (() -> Void) - var hasValue: Bool - init( _ label: String, text: Binding, @@ -43,8 +41,7 @@ struct PanelTextField: View @ViewBuilder trailingAccessories: () -> TrailingAccessories? = { EmptyView() }, helperText: String? = nil, clearable: Bool? = false, - onClear: (() -> Void)? = {}, - hasValue: Bool? = false + onClear: (() -> Void)? = {} ) { self.label = label _text = text @@ -54,15 +51,14 @@ struct PanelTextField: View self.helperText = helperText ?? nil self.clearable = clearable ?? false self.onClear = onClear ?? {} - self.hasValue = hasValue ?? false } @ViewBuilder public func selectionBackground( _ isFocused: Bool = false ) -> some View { - if self.controlActive != .inactive || !text.isEmpty || hasValue { - if isFocused || !text.isEmpty || hasValue { + if self.controlActive != .inactive || !text.isEmpty { + if isFocused || !text.isEmpty { Color(.textBackgroundColor) } else { if colorScheme == .light { @@ -135,7 +131,7 @@ struct PanelTextField: View ) .overlay( RoundedRectangle(cornerRadius: 6) - .stroke(isFocused || !text.isEmpty || hasValue ? .tertiary : .quaternary, lineWidth: 1.25) + .stroke(isFocused || !text.isEmpty ? .tertiary : .quaternary, lineWidth: 1.25) .clipShape(RoundedRectangle(cornerRadius: 6)) .disabled(true) .edgesIgnoringSafeArea(.all)