From 597542fd82aa261c9d667dee2cdfe34d8932e300 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:32:31 -0500 Subject: [PATCH 1/4] Begin Refactor --- .../CodeEditUI/PanelTextField.swift | 12 +- .../TextViewController+Cursor.swift | 6 +- .../TextViewController+FindPanelTarget.swift | 8 +- .../TextViewController+LoadView.swift | 2 +- .../TextViewController+StyleViews.swift | 2 +- .../Find/FindPanelDelegate.swift | 24 -- .../Find/FindPanelTarget.swift | 5 +- ...FindViewController+FindPanelDelegate.swift | 221 ------------------ .../Find/FindViewController+Operations.swift | 203 ---------------- .../Find/FindViewController+Toggle.swift | 30 +-- .../Find/FindViewController.swift | 48 +--- .../Find/PanelView/FindBarView.swift | 106 +++++++++ .../Find/PanelView/FindModePicker.swift | 17 +- .../Find/PanelView/FindPanel.swift | 88 +------ .../Find/PanelView/FindPanelMode.swift | 20 ++ .../Find/PanelView/FindPanelView.swift | 196 +++------------- .../Find/PanelView/FindPanelViewModel.swift | 116 --------- .../Find/PanelView/ReplaceBarView.swift | 71 ++++++ .../FindPanelViewModel+Emphasis.swift | 37 +++ .../ViewModel/FindPanelViewModel+Find.swift | 81 +++++++ .../ViewModel/FindPanelViewModel+Move.swift | 65 ++++++ .../FindPanelViewModel+Replace.swift | 58 +++++ .../Find/ViewModel/FindPanelViewModel.swift | 95 ++++++++ 23 files changed, 631 insertions(+), 880 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Find/FindPanelDelegate.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+FindPanelDelegate.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/FindViewController+Operations.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindBarView.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelMode.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindPanelViewModel.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceBarView.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift create mode 100644 Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift b/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift index cda97dbca..9c66afc67 100644 --- a/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift +++ b/Sources/CodeEditSourceEditor/CodeEditUI/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) 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 d6ae4ee21..2c79d11c2 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) { scrollView.contentInsets.top += panelHeight gutterView.frame.origin.y = -scrollView.contentInsets.top @@ -20,7 +24,7 @@ extension TextViewController: FindPanelTarget { } func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) { - scrollView.contentInsets.top += mode == .replace ? panelHeight/2 : -panelHeight + scrollView.contentInsets.top += mode == .replace ? panelHeight : -(panelHeight/2) gutterView.frame.origin.y = -scrollView.contentInsets.top } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index a4e2cf76d..3a89a76bf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -155,7 +155,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 adccc1996..8c9c9b11f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -78,7 +78,7 @@ extension TextViewController { scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 - scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) + scrollView.contentInsets.top += (findViewController?.viewModel.isShowingFindPanel ?? false) ? findViewController?.panelHeight ?? 0 : 0 } 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/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index f1857ecb0..f411766c7 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -5,15 +5,16 @@ // 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) 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..75237fb9b 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -16,18 +16,17 @@ 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() + _ = findPanel.becomeFirstResponder() 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 @@ -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: 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: 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) } } diff --git a/Sources/CodeEditSourceEditor/Find/FindViewController.swift b/Sources/CodeEditSourceEditor/Find/FindViewController.swift index 1e2d2f05d..5db469b10 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -10,33 +10,22 @@ import CodeEditTextView /// Creates a container controller for displaying and hiding a find panel with a content view. final class FindViewController: NSViewController { - weak var target: FindPanelTarget? + var viewModel: FindPanelViewModel /// The amount of padding from the top of the view to inset the find panel by. /// When set, the safe area is ignored, and the top padding is measured from the top of the view's frame. var topPadding: CGFloat? { didSet { - if isShowingFindPanel { + if viewModel.isShowingFindPanel { setFindPanelConstraintShow() } } } var childView: NSView - var findPanel: FindPanel! - var findMatches: [NSRange] = [] - - // 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: FindPanel 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 { @@ -45,34 +34,17 @@ final class FindViewController: NSViewController { /// The height of the find panel. var panelHeight: CGFloat { - return self.mode == .replace ? 56 : 28 + print(findPanel.intrinsicContentSize.height) + return viewModel.mode == .replace ? 56 : 28 } init(target: FindPanelTarget, childView: NSView) { - self.target = target + viewModel = FindPanelViewModel(target: target) self.childView = childView + findPanel = FindPanel(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 +87,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/FindBarView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindBarView.swift new file mode 100644 index 000000000..b878f9a3f --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindBarView.swift @@ -0,0 +1,106 @@ +// +// FindBarView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct FindBarView: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + HStack(spacing: 5) { + 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() + } + 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()) + } +// .background(GeometryReader { geometry in +// Color.clear.onAppear { +// findControlsWidth = geometry.size.width +// } +// .onChange(of: geometry.size.width) { newWidth in +// findControlsWidth = newWidth +// } +// }) + } + .padding(.horizontal, 5) + } +} 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 index 1d9dd8b78..12fbb76b2 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift @@ -10,75 +10,33 @@ 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? +final class FindPanel: NSHostingView { + private weak var viewModel: FindPanelViewModel? - init(delegate: FindPanelDelegate?, textView: NSView?) { - self.findDelegate = delegate - self.textView = textView - super.init(frame: .zero) + private var eventMonitor: Any? - viewModel = FindPanelViewModel(delegate: findDelegate) - viewModel.findText = findQueryText // Initialize with stored value - hostingView = NSHostingView(rootView: FindPanelView(viewModel: viewModel)) - hostingView.translatesAutoresizingMaskIntoConstraints = false + init(viewModel: FindPanelViewModel) { + self.viewModel = viewModel + super.init(rootView: FindPanelView(viewModel: viewModel)) - // Make the NSHostingView transparent - hostingView.wantsLayer = true - hostingView.layer?.backgroundColor = .clear + self.translatesAutoresizingMaskIntoConstraints = false - // 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() + @MainActor @preconcurrency required init(rootView: FindPanelView) { + super.init(rootView: rootView) } 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 + deinit { + removeEventMonitor() } // MARK: - Event Monitor Management @@ -86,7 +44,7 @@ final class FindPanel: NSView { func addEventMonitor() { eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event -> NSEvent? in if event.keyCode == 53 { // if esc pressed - self.dismiss() + self.viewModel?.dismiss?() return nil // do not play "beep" sound } return event @@ -99,26 +57,4 @@ final class FindPanel: NSView { 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/FindPanelMode.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelMode.swift new file mode 100644 index 000000000..f7bbf26bd --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/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/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index edf115f9e..158dafaa4 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -10,182 +10,52 @@ 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()) - } - .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) + FindBarView(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) 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() - } - } - .padding(.horizontal, 5) + ReplaceBarView(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) } } .frame(height: viewModel.panelHeight) .background(.bar) - .onChange(of: viewModel.findText) { newValue in - viewModel.onFindTextChange(newValue) - } - .onChange(of: viewModel.replaceText) { newValue in - viewModel.onReplaceTextChange(newValue) - } - .onChange(of: viewModel.mode) { newMode in - viewModel.onModeChange(newMode) + .onChange(of: focus) { newValue in + viewModel.isFocused = newValue != nil } - .onChange(of: viewModel.wrapAround) { newValue in - viewModel.onWrapAroundChange(newValue) - } - .onChange(of: viewModel.matchCase) { newValue in - viewModel.onMatchCaseChange(newValue) + .onChange(of: viewModel.findText) { _ in + viewModel.findTextDidChange() } +// .onChange(of: viewModel.mode) { newMode in + // +// } +// .onChange(of: viewModel.wrapAround) { newValue in +// viewModel.onWrapAroundChange(newValue) +// } +// .onChange(of: viewModel.matchCase) { newValue in +// viewModel.onMatchCaseChange(newValue) +// } .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() } } } 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/ReplaceBarView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceBarView.swift new file mode 100644 index 000000000..a9c48df1e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceBarView.swift @@ -0,0 +1,71 @@ +// +// ReplaceBarView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import SwiftUI + +struct ReplaceBarView: View { + @ObservedObject var viewModel: FindPanelViewModel + @FocusState.Binding var focus: FindPanelView.FindPanelFocus? + @Binding var findModePickerWidth: CGFloat + + var body: some View { + 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: findModePickerWidth, alignment: .leading) + Divider() + }, + clearable: true + ) + .controlSize(.small) + .focused($focus, equals: .replace) + HStack(spacing: 4) { + ControlGroup { + Button { + viewModel.replace(all: false) + } label: { + 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 { + viewModel.replace(all: true) + } label: { + 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() + } + } + .padding(.horizontal, 5) + } +} 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..3b5f02eb5 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -0,0 +1,81 @@ +// +// FindPanelViewModel+Find.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation + +extension FindPanelViewModel { + // MARK: - Find + + func find() { + // Don't find if target or emphasisManager isn't ready or the query is empty + guard let target = target, !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..659ffd2c1 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -0,0 +1,65 @@ +// +// 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 } + let isFirstResponder = target.findPanelTargetView.window?.firstResponder === target.findPanelTargetView + + 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: isFirstResponder) } + + 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..0c11d353d --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -0,0 +1,58 @@ +// +// FindPanelViewModel+Replace.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/18/25. +// + +import Foundation +import CodeEditTextView + +extension FindPanelViewModel { + 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() + + let sortedMatches = findMatches.sorted(by: { $0.location > $1.location }) + for idx in sortedMatches.indices { + replaceMatch(index: idx, target: target, textView: textViewController.textView) + } + textViewController.textView.undoManager?.endUndoGrouping() + + if let lastMatch = sortedMatches.first { + target.setCursorPositions( + [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], + scrollToVisible: true + ) + } + + updateMatches([]) + } else { + replaceMatch(index: currentFindMatchIndex, target: target, textView: textViewController.textView) + } + + // Update the emphases + addMatchEmphases(flashCurrent: true) + } + + private func replaceMatch(index: Int, target: FindPanelTarget, textView: TextView) { + let range = findMatches[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 findMatches.dropFirst(index).indices { + findMatches[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..8e7e39a11 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -0,0 +1,95 @@ +// +// 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 + + @Published var isFocused: Bool = false + + @Published var matchCase: Bool = false + @Published var wrapAround: Bool = true + + var panelHeight: CGFloat { + return mode == .replace ? 56 : 28 + } + + var matchCount: Int { + findMatches.count + } + + private var cancellables: Set = [] + + 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 + ) + } + + $mode + .sink { newMode in + self.target?.findPanelModeDidChange(to: newMode, panelHeight: self.panelHeight) + } + .store(in: &cancellables) + } + + // MARK: - Update Matches + + func updateMatches(_ newMatches: [NSRange]) { + findMatches = newMatches + currentFindMatchIndex = newMatches.isEmpty ? nil : 0 + } + + // MARK: - Text Listeners + + @objc private func textDidChange() { + // Only update if we have find text + if !findText.isEmpty { + find() + } + } + + 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() + } +} From ef9e244a614f33fd47b9434f86d49a0fdf159ce0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:03:58 -0500 Subject: [PATCH 2/4] Finish Refactor --- .../xcshareddata/swiftpm/Package.resolved | 9 ++ .../TextViewController+FindPanelTarget.swift | 5 +- .../TextViewController+StyleViews.swift | 6 +- .../Find/{PanelView => }/FindPanelMode.swift | 0 .../Find/FindPanelTarget.swift | 2 +- .../Find/FindViewController+Toggle.swift | 10 +- .../Find/FindViewController.swift | 10 +- .../Find/PanelView/FindBarView.swift | 106 ------------------ ...Panel.swift => FindPanelHostingView.swift} | 4 +- .../Find/PanelView/FindPanelView.swift | 105 ++++++++++++++--- .../Find/PanelView/FindSearchField.swift | 64 +++++++++++ .../Find/PanelView/ReplaceBarView.swift | 71 ------------ .../Find/PanelView/ReplaceSearchField.swift | 35 ++++++ .../ViewModel/FindPanelViewModel+Find.swift | 2 +- .../ViewModel/FindPanelViewModel+Move.swift | 3 +- .../FindPanelViewModel+Replace.swift | 29 +++-- .../Find/ViewModel/FindPanelViewModel.swift | 19 ++-- 17 files changed, 248 insertions(+), 232 deletions(-) rename Sources/CodeEditSourceEditor/Find/{PanelView => }/FindPanelMode.swift (100%) delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindBarView.swift rename Sources/CodeEditSourceEditor/Find/PanelView/{FindPanel.swift => FindPanelHostingView.swift} (93%) create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/FindSearchField.swift delete mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceBarView.swift create mode 100644 Sources/CodeEditSourceEditor/Find/PanelView/ReplaceSearchField.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a1eb3b548..3f475425b 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeeditsymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditSymbols.git", + "state" : { + "revision" : "ae69712b08571c4469c2ed5cd38ad9f19439793e", + "version" : "0.2.3" + } + }, { "identity" : "codeedittextview", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 282510a0a..3401ea3cf 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -21,9 +21,8 @@ extension TextViewController: FindPanelTarget { updateContentInsets() } - func findPanelModeDidChange(to mode: FindPanelMode, panelHeight: CGFloat) { - scrollView.contentInsets.top += mode == .replace ? panelHeight : -(panelHeight/2) - gutterView.frame.origin.y = -scrollView.contentInsets.top + func findPanelModeDidChange(to mode: FindPanelMode) { + updateContentInsets() } var emphasisManager: EmphasisManager? { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index bcf0a6869..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) ? FindPanel.height : 0 + let findInset: CGFloat = if findViewController?.viewModel.isShowingFindPanel ?? false { + findViewController?.viewModel.panelHeight ?? 0 + } else { + 0 + } scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelMode.swift b/Sources/CodeEditSourceEditor/Find/FindPanelMode.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Find/PanelView/FindPanelMode.swift rename to Sources/CodeEditSourceEditor/Find/FindPanelMode.swift diff --git a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift index f411766c7..90a286715 100644 --- a/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Find/FindPanelTarget.swift @@ -19,5 +19,5 @@ protocol FindPanelTarget: AnyObject { 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+Toggle.swift b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift index 75237fb9b..bfea53c92 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController+Toggle.swift @@ -18,7 +18,7 @@ extension FindViewController { func showFindPanel(animated: Bool = true) { if viewModel.isShowingFindPanel { // If panel is already showing, just focus the text field - _ = findPanel.becomeFirstResponder() + viewModel.isFocused = true return } @@ -30,7 +30,7 @@ extension FindViewController { // 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() @@ -38,7 +38,7 @@ 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. - viewModel.target?.findPanelWillShow(panelHeight: panelHeight) + viewModel.target?.findPanelWillShow(panelHeight: viewModel.panelHeight) setFindPanelConstraintShow() } onComplete: { } @@ -59,7 +59,7 @@ extension FindViewController { findPanel.removeEventMonitor() conditionalAnimated(animated) { - viewModel.target?.findPanelWillHide(panelHeight: panelHeight) + viewModel.target?.findPanelWillHide(panelHeight: viewModel.panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in self?.findPanel.isHidden = true @@ -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 5db469b10..a9e2dd3b0 100644 --- a/Sources/CodeEditSourceEditor/Find/FindViewController.swift +++ b/Sources/CodeEditSourceEditor/Find/FindViewController.swift @@ -23,7 +23,7 @@ final class FindViewController: NSViewController { } var childView: NSView - var findPanel: FindPanel + var findPanel: FindPanelHostingView var findPanelVerticalConstraint: NSLayoutConstraint! /// The 'real' top padding amount. @@ -32,16 +32,10 @@ final class FindViewController: NSViewController { (topPadding ?? view.safeAreaInsets.top) } - /// The height of the find panel. - var panelHeight: CGFloat { - print(findPanel.intrinsicContentSize.height) - return viewModel.mode == .replace ? 56 : 28 - } - init(target: FindPanelTarget, childView: NSView) { viewModel = FindPanelViewModel(target: target) self.childView = childView - findPanel = FindPanel(viewModel: viewModel) + findPanel = FindPanelHostingView(viewModel: viewModel) super.init(nibName: nil, bundle: nil) viewModel.dismiss = { [weak self] in self?.hideFindPanel() diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindBarView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindBarView.swift deleted file mode 100644 index b878f9a3f..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindBarView.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// FindBarView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/18/25. -// - -import SwiftUI - -struct FindBarView: View { - @ObservedObject var viewModel: FindPanelViewModel - @FocusState.Binding var focus: FindPanelView.FindPanelFocus? - @Binding var findModePickerWidth: CGFloat - - var body: some View { - HStack(spacing: 5) { - 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() - } - 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()) - } -// .background(GeometryReader { geometry in -// Color.clear.onAppear { -// findControlsWidth = geometry.size.width -// } -// .onChange(of: geometry.size.width) { newWidth in -// findControlsWidth = newWidth -// } -// }) - } - .padding(.horizontal, 5) - } -} diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift similarity index 93% rename from Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift rename to Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift index 12fbb76b2..a02e4f7c6 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanel.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelHostingView.swift @@ -1,5 +1,5 @@ // -// FindPanel.swift +// FindPanelHostingView.swift // CodeEditSourceEditor // // Created by Khan Winter on 3/10/25. @@ -10,7 +10,7 @@ import AppKit import Combine // NSView wrapper for using SwiftUI view in AppKit -final class FindPanel: NSHostingView { +final class FindPanelHostingView: NSHostingView { private weak var viewModel: FindPanelViewModel? private var eventMonitor: Any? diff --git a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift index 158dafaa4..7b8fb551e 100644 --- a/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift +++ b/Sources/CodeEditSourceEditor/Find/PanelView/FindPanelView.swift @@ -22,12 +22,23 @@ struct FindPanelView: View { @FocusState private var focus: FindPanelFocus? var body: some View { - VStack(alignment: .leading, spacing: 5) { - FindBarView(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) - if viewModel.mode == .replace { - ReplaceBarView(viewModel: viewModel, focus: $focus, findModePickerWidth: $findModePickerWidth) + 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) + } + } + VStack(alignment: .leading, spacing: 4) { + doneNextControls + if viewModel.mode == .replace { + Spacer(minLength: 0) + replaceControls + } } + .fixedSize() } + .padding(.horizontal, 5) .frame(height: viewModel.panelHeight) .background(.bar) .onChange(of: focus) { newValue in @@ -36,15 +47,12 @@ struct FindPanelView: View { .onChange(of: viewModel.findText) { _ in viewModel.findTextDidChange() } -// .onChange(of: viewModel.mode) { newMode in - // -// } -// .onChange(of: viewModel.wrapAround) { newValue in -// viewModel.onWrapAroundChange(newValue) -// } -// .onChange(of: viewModel.matchCase) { newValue in -// viewModel.onMatchCaseChange(newValue) -// } + .onChange(of: viewModel.wrapAround) { _ in + viewModel.find() + } + .onChange(of: viewModel.matchCase) { _ in + viewModel.find() + } .onChange(of: viewModel.isFocused) { newValue in if newValue { if focus == nil { @@ -59,6 +67,77 @@ struct FindPanelView: View { } } } + + @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) + } } private struct FindModePickerWidthPreferenceKey: PreferenceKey { 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/ReplaceBarView.swift b/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceBarView.swift deleted file mode 100644 index a9c48df1e..000000000 --- a/Sources/CodeEditSourceEditor/Find/PanelView/ReplaceBarView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// ReplaceBarView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/18/25. -// - -import SwiftUI - -struct ReplaceBarView: View { - @ObservedObject var viewModel: FindPanelViewModel - @FocusState.Binding var focus: FindPanelView.FindPanelFocus? - @Binding var findModePickerWidth: CGFloat - - var body: some View { - 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: findModePickerWidth, alignment: .leading) - Divider() - }, - clearable: true - ) - .controlSize(.small) - .focused($focus, equals: .replace) - HStack(spacing: 4) { - ControlGroup { - Button { - viewModel.replace(all: false) - } label: { - 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 { - viewModel.replace(all: true) - } label: { - 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() - } - } - .padding(.horizontal, 5) - } -} 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+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index 3b5f02eb5..3db5755d8 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -12,7 +12,7 @@ extension FindPanelViewModel { func find() { // Don't find if target or emphasisManager isn't ready or the query is empty - guard let target = target, !findText.isEmpty else { + guard let target = target, isFocused, !findText.isEmpty else { updateMatches([]) return } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift index 659ffd2c1..66b0d6b2b 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -18,7 +18,6 @@ extension FindPanelViewModel { private func moveMatch(forwards: Bool) { guard let target = target else { return } - let isFirstResponder = target.findPanelTargetView.window?.firstResponder === target.findPanelTargetView guard !findMatches.isEmpty else { showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) @@ -26,7 +25,7 @@ extension FindPanelViewModel { } // From here on out we want to emphasize the result no matter what - defer { addMatchEmphases(flashCurrent: isFirstResponder) } + defer { addMatchEmphases(flashCurrent: isTargetFirstResponder) } guard let currentFindMatchIndex else { self.currentFindMatchIndex = 0 diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift index 0c11d353d..ce17bc80c 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -19,14 +19,17 @@ extension FindPanelViewModel { if all { textViewController.textView.undoManager?.beginUndoGrouping() + textViewController.textView.textStorage.beginEditing() - let sortedMatches = findMatches.sorted(by: { $0.location > $1.location }) - for idx in sortedMatches.indices { - replaceMatch(index: idx, target: target, textView: textViewController.textView) + var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) + for (idx, _) in sortedMatches.enumerated().reversed() { + replaceMatch(index: idx, target: target, textView: textViewController.textView, matches: &sortedMatches) } + + textViewController.textView.textStorage.endEditing() textViewController.textView.undoManager?.endUndoGrouping() - if let lastMatch = sortedMatches.first { + if let lastMatch = sortedMatches.last { target.setCursorPositions( [CursorPosition(range: NSRange(location: lastMatch.location, length: 0))], scrollToVisible: true @@ -35,24 +38,30 @@ extension FindPanelViewModel { updateMatches([]) } else { - replaceMatch(index: currentFindMatchIndex, target: target, textView: textViewController.textView) + replaceMatch( + index: currentFindMatchIndex, + target: target, + textView: textViewController.textView, + matches: &findMatches + ) + updateMatches(findMatches) } // Update the emphases addMatchEmphases(flashCurrent: true) } - private func replaceMatch(index: Int, target: FindPanelTarget, textView: TextView) { - let range = findMatches[index] + private func replaceMatch(index: Int, target: FindPanelTarget, textView: TextView, matches: inout [NSRange]) { + let range = matches[index] // Set cursor positions to the match range - textView.replaceCharacters(in: [range], with: replaceText) + 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 findMatches.dropFirst(index).indices { - findMatches[idx].location -= lengthDiff + 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 index 8e7e39a11..033353414 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -19,22 +19,29 @@ class FindPanelViewModel: ObservableObject { @Published var findText: String = "" @Published var replaceText: String = "" - @Published var mode: FindPanelMode = .find + @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 ? 56 : 28 + return mode == .replace ? 54 : 28 } var matchCount: Int { findMatches.count } - private var cancellables: Set = [] + var isTargetFirstResponder: Bool { + target?.findPanelTargetView.window?.firstResponder === target?.findPanelTargetView + } init(target: FindPanelTarget) { self.target = target @@ -48,12 +55,6 @@ class FindPanelViewModel: ObservableObject { object: textViewController.textView ) } - - $mode - .sink { newMode in - self.target?.findPanelModeDidChange(to: newMode, panelHeight: self.panelHeight) - } - .store(in: &cancellables) } // MARK: - Update Matches From a98f65b823f168f3f744a427ffed3e0026cc6241 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:19:33 -0500 Subject: [PATCH 3/4] Docs --- .../ViewModel/FindPanelViewModel+Find.swift | 4 +++- .../FindPanelViewModel+Replace.swift | 20 ++++++++++--------- .../Find/ViewModel/FindPanelViewModel.swift | 3 +++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index 3db5755d8..28a10c20e 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -9,7 +9,9 @@ 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 { diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift index ce17bc80c..9351eefd1 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -9,6 +9,8 @@ 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, @@ -23,7 +25,7 @@ extension FindPanelViewModel { var sortedMatches = findMatches.sorted(by: { $0.location < $1.location }) for (idx, _) in sortedMatches.enumerated().reversed() { - replaceMatch(index: idx, target: target, textView: textViewController.textView, matches: &sortedMatches) + replaceMatch(index: idx, textView: textViewController.textView, matches: &sortedMatches) } textViewController.textView.textStorage.endEditing() @@ -38,20 +40,20 @@ extension FindPanelViewModel { updateMatches([]) } else { - replaceMatch( - index: currentFindMatchIndex, - target: target, - textView: textViewController.textView, - matches: &findMatches - ) + replaceMatch(index: currentFindMatchIndex, textView: textViewController.textView, matches: &findMatches) updateMatches(findMatches) } // Update the emphases addMatchEmphases(flashCurrent: true) } - - private func replaceMatch(index: Int, target: FindPanelTarget, textView: TextView, matches: inout [NSRange]) { + + /// 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) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift index 033353414..19f62018b 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel.swift @@ -35,6 +35,7 @@ class FindPanelViewModel: ObservableObject { return mode == .replace ? 54 : 28 } + /// The number of current find matches. var matchCount: Int { findMatches.count } @@ -66,6 +67,7 @@ class FindPanelViewModel: ObservableObject { // 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 { @@ -73,6 +75,7 @@ class FindPanelViewModel: ObservableObject { } } + /// 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, From e27e5cd06ec96e0d98e4f23b572dc60303ccd5db Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:24:14 -0500 Subject: [PATCH 4/4] Lint Fixes --- .../Find/ViewModel/FindPanelViewModel+Find.swift | 2 +- .../Find/ViewModel/FindPanelViewModel+Replace.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index 28a10c20e..438df586a 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -9,7 +9,7 @@ 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() { diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift index 9351eefd1..278765b1f 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Replace.swift @@ -47,7 +47,7 @@ extension FindPanelViewModel { // 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.