From 309bacc5e73ddcd5c3daff742663a5828b38cd0d Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Mon, 2 Jun 2025 13:27:03 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Adds=20Select=20Next=20Occurrence=20(?= =?UTF-8?q?=E2=87=A7=E2=8C=A5=E2=8C=98E)=20and=20Select=20Previous=20Occur?= =?UTF-8?q?rence=20(=E2=87=A7=E2=8C=A5=E2=8C=98E)=20commands.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TextViewController+LoadView.swift | 8 + .../Controller/TextViewController.swift | 50 +++++ .../FindPanelViewModel+Emphasis.swift | 8 +- .../ViewModel/FindPanelViewModel+Find.swift | 187 +++++++++++++++++- .../ViewModel/FindPanelViewModel+Move.swift | 69 +++++-- .../FindPanelTests.swift | 61 ++++++ 6 files changed, 363 insertions(+), 20 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 9b7028a9e..d705442b7 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -201,6 +201,8 @@ extension TextViewController { func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? { let commandKey = NSEvent.ModifierFlags.command.rawValue + let optionKey = NSEvent.ModifierFlags.option.rawValue + let shiftKey = NSEvent.ModifierFlags.shift.rawValue switch (modifierFlags, event.charactersIgnoringModifiers) { case (commandKey, "/"): @@ -219,6 +221,12 @@ extension TextViewController { case (0, "\u{1b}"): // Escape key self.findViewController?.hideFindPanel() return nil + case (commandKey | optionKey | shiftKey, "E"): // ⇧ ⌥ ⌘ E - uppercase letter because shiftKey is present + selectPreviousOccurrence(nil) + return nil + case (commandKey | optionKey, "e"): // ⌥ ⌘ E + selectNextOccurrence(nil) + return nil case (_, _): return event } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 8d3b8b69f..0370cbdad 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -21,6 +21,9 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification") weak var findViewController: FindViewController? + var findPanelViewModel: FindPanelViewModel? { + findViewController?.viewModel + } var scrollView: NSScrollView! var textView: TextView! @@ -391,4 +394,51 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty } localEvenMonitor = nil } + + // MARK: - Multiple Selection Commands + + @objc func selectNextOccurrence(_ sender: Any?) { + guard let findPanelViewModel = findPanelViewModel else { return } + findPanelViewModel.selectNextOccurrence() + } + + @objc func selectPreviousOccurrence(_ sender: Any?) { + guard let findPanelViewModel = findPanelViewModel else { return } + findPanelViewModel.selectPreviousOccurrence() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + // Initialize find view controller if not already set + if findViewController == nil { + let findVC = FindViewController(target: self, childView: view) + addChild(findVC) + view.addSubview(findVC.view) + + // Set up constraints + findVC.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + findVC.view.topAnchor.constraint(equalTo: view.topAnchor), + findVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + findVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + findViewController = findVC + } + } } + +// MARK: - NSMenuItemValidation + +extension TextViewController: NSMenuItemValidation { + public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + switch menuItem.action { + case #selector(selectNextOccurrence(_:)), #selector(selectPreviousOccurrence(_:)): + return textView.selectedRange.length > 0 + default: + return true + } + } +} + diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift index adcebcd8e..01080505d 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift @@ -8,7 +8,7 @@ import CodeEditTextView extension FindPanelViewModel { - func addMatchEmphases(flashCurrent: Bool) { + func addMatchEmphases(flashCurrent: Bool, allowSelection: Bool = true) { guard let target = target, let emphasisManager = target.textView.emphasisManager else { return } @@ -23,7 +23,7 @@ extension FindPanelViewModel { style: .standard, flash: flashCurrent && index == currentFindMatchIndex, inactive: index != currentFindMatchIndex, - selectInDocument: index == currentFindMatchIndex + selectInDocument: allowSelection && index == currentFindMatchIndex ) } @@ -31,7 +31,7 @@ extension FindPanelViewModel { emphasisManager.addEmphases(emphases, for: EmphasisGroup.find) } - func flashCurrentMatch() { + func flashCurrentMatch(allowSelection: Bool = true) { guard let target = target, let emphasisManager = target.textView.emphasisManager, let currentFindMatchIndex else { @@ -50,7 +50,7 @@ extension FindPanelViewModel { style: .standard, flash: true, inactive: false, - selectInDocument: true + selectInDocument: allowSelection ) ) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift index ded2f09fa..e30de2875 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift @@ -6,6 +6,7 @@ // import Foundation +import CodeEditTextView extension FindPanelViewModel { // MARK: - Find @@ -64,8 +65,10 @@ extension FindPanelViewModel { self.findMatches = matches.map(\.range) - // Find the nearest match to the current cursor position - currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) + // Only set currentFindMatchIndex if we're not doing multiple selection + if !isFocused { + currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches) + } // Only add emphasis layers if the find panel is focused if isFocused { @@ -115,4 +118,184 @@ extension FindPanelViewModel { return bestIndex >= 0 ? bestIndex : nil } + // MARK: - Multiple Selection Support + + /// Selects the next occurrence of the current selection while maintaining existing selections + func selectNextOccurrence() { + guard let target = target, + let currentSelection = target.cursorPositions.first?.range else { + return + } + + // Set find text to the current selection + let selectedText = (target.textView.string as NSString).substring(with: currentSelection) + + // Only update findText if it's different from the current selection + if findText != selectedText { + findText = selectedText + // Clear existing matches since we're searching for something new + findMatches = [] + currentFindMatchIndex = nil + } + + // Perform find if we haven't already + if findMatches.isEmpty { + find() + } + + // Find the next unselected match + let selectedRanges = target.cursorPositions.map { $0.range } + + // Find the index of the current selection + if let currentIndex = findMatches.firstIndex(where: { $0.location == currentSelection.location }) { + // Find the next unselected match + var nextIndex = (currentIndex + 1) % findMatches.count + var wrappedAround = false + + while selectedRanges.contains(where: { $0.location == findMatches[nextIndex].location }) { + nextIndex = (nextIndex + 1) % findMatches.count + // If we've gone all the way around, break to avoid infinite loop + if nextIndex == currentIndex { + // If we've wrapped around and still haven't found an unselected match, + // show the "no more matches" notification and flash the current match + showWrapNotification(forwards: true, error: true, targetView: target.findPanelTargetView) + if let currentIndex = currentFindMatchIndex { + target.textView.emphasisManager?.addEmphases([ + Emphasis( + range: findMatches[currentIndex], + style: .standard, + flash: true, + inactive: false, + selectInDocument: false + ) + ], for: EmphasisGroup.find) + } + return + } + // If we've wrapped around once, set the flag + if nextIndex == 0 { + wrappedAround = true + } + } + + // If we wrapped around and wrapAround is false, show the "no more matches" notification + if wrappedAround && !wrapAround { + showWrapNotification(forwards: true, error: true, targetView: target.findPanelTargetView) + if let currentIndex = currentFindMatchIndex { + target.textView.emphasisManager?.addEmphases([ + Emphasis( + range: findMatches[currentIndex], + style: .standard, + flash: true, + inactive: false, + selectInDocument: false + ) + ], for: EmphasisGroup.find) + } + return + } + + // If we wrapped around and wrapAround is true, show the wrap notification + if wrappedAround { + showWrapNotification(forwards: true, error: false, targetView: target.findPanelTargetView) + } + + currentFindMatchIndex = nextIndex + } else { + currentFindMatchIndex = nil + } + + // Use the existing moveMatch function with keepExistingSelections enabled + moveMatch(forwards: true, keepExistingSelections: true) + } + + /// Selects the previous occurrence of the current selection while maintaining existing selections + func selectPreviousOccurrence() { + guard let target = target, + let currentSelection = target.cursorPositions.first?.range else { + return + } + + // Set find text to the current selection + let selectedText = (target.textView.string as NSString).substring(with: currentSelection) + + // Only update findText if it's different from the current selection + if findText != selectedText { + findText = selectedText + // Clear existing matches since we're searching for something new + findMatches = [] + currentFindMatchIndex = nil + } + + // Perform find if we haven't already + if findMatches.isEmpty { + find() + } + + // Find the previous unselected match + let selectedRanges = target.cursorPositions.map { $0.range } + + // Find the index of the current selection + if let currentIndex = findMatches.firstIndex(where: { $0.location == currentSelection.location }) { + // Find the previous unselected match + var prevIndex = (currentIndex - 1 + findMatches.count) % findMatches.count + var wrappedAround = false + + while selectedRanges.contains(where: { $0.location == findMatches[prevIndex].location }) { + prevIndex = (prevIndex - 1 + findMatches.count) % findMatches.count + // If we've gone all the way around, break to avoid infinite loop + if prevIndex == currentIndex { + // If we've wrapped around and still haven't found an unselected match, + // show the "no more matches" notification and flash the current match + showWrapNotification(forwards: false, error: true, targetView: target.findPanelTargetView) + if let currentIndex = currentFindMatchIndex { + target.textView.emphasisManager?.addEmphases([ + Emphasis( + range: findMatches[currentIndex], + style: .standard, + flash: true, + inactive: false, + selectInDocument: false + ) + ], for: EmphasisGroup.find) + } + return + } + // If we've wrapped around once, set the flag + if prevIndex == findMatches.count - 1 { + wrappedAround = true + } + } + + // If we wrapped around and wrapAround is false, show the "no more matches" notification + if wrappedAround && !wrapAround { + showWrapNotification(forwards: false, error: true, targetView: target.findPanelTargetView) + if let currentIndex = currentFindMatchIndex { + target.textView.emphasisManager?.addEmphases([ + Emphasis( + range: findMatches[currentIndex], + style: .standard, + flash: true, + inactive: false, + selectInDocument: false + ) + ], for: EmphasisGroup.find) + } + return + } + + // If we wrapped around and wrapAround is true, show the wrap notification + if wrappedAround { + showWrapNotification(forwards: false, error: false, targetView: target.findPanelTargetView) + } + + currentFindMatchIndex = prevIndex + } else { + currentFindMatchIndex = nil + } + + // Use the existing moveMatch function with keepExistingSelections enabled + moveMatch(forwards: false, keepExistingSelections: true) + } + } diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift index 726598b7c..141659b97 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -16,7 +16,7 @@ extension FindPanelViewModel { moveMatch(forwards: false) } - private func moveMatch(forwards: Bool) { + func moveMatch(forwards: Bool, keepExistingSelections: Bool = false) { guard let target = target else { return } guard !findMatches.isEmpty else { @@ -27,9 +27,9 @@ extension FindPanelViewModel { // From here on out we want to emphasize the result no matter what defer { if isTargetFirstResponder { - flashCurrentMatch() + flashCurrentMatch(allowSelection: !keepExistingSelections) } else { - addMatchEmphases(flashCurrent: isTargetFirstResponder) + addMatchEmphases(flashCurrent: isTargetFirstResponder, allowSelection: !keepExistingSelections) } } @@ -38,23 +38,64 @@ extension FindPanelViewModel { return } - let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 - guard !isAtLimit || wrapAround else { - showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) - return - } + // Only increment/decrement the index if we're not keeping existing selections + if !keepExistingSelections { + let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 - self.currentFindMatchIndex = if forwards { - (currentFindMatchIndex + 1) % findMatches.count + 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) + } } else { - (currentFindMatchIndex - 1 + (findMatches.count)) % findMatches.count + // When keeping existing selections, we still need to respect wrapAround + let isAtLimit = forwards ? currentFindMatchIndex == findMatches.count - 1 : currentFindMatchIndex == 0 + + if isAtLimit && !wrapAround { + showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) + return + } + + if isAtLimit && wrapAround { + showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) + } } - if isAtLimit { - showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) + + // If keeping existing selections, add the new match to them + if keepExistingSelections { + let newRange = findMatches[self.currentFindMatchIndex!] + var newRanges = target.textView.selectionManager.textSelections.map { $0.range } + + // Add the new range if it's not already selected + if !newRanges.contains(where: { $0.location == newRange.location && $0.length == newRange.length }) { + newRanges.append(newRange) + } + + // Set all selections at once + target.textView.selectionManager.setSelectedRanges(newRanges) + + // Update cursor positions to match + var newPositions = target.cursorPositions + newPositions.append(CursorPosition(range: newRange)) + target.setCursorPositions(newPositions, scrollToVisible: true) } } - private func showWrapNotification(forwards: Bool, error: Bool, targetView: NSView) { + /// Shows a bezel notification for wrap around or end of search + /// - Parameters: + /// - forwards: Whether we're moving forwards or backwards + /// - error: Whether this is an error (no more matches) or a wrap around + /// - targetView: The view to show the notification over + func showWrapNotification(forwards: Bool, error: Bool, targetView: NSView) { if error { NSSound.beep() } diff --git a/Tests/CodeEditSourceEditorTests/FindPanelTests.swift b/Tests/CodeEditSourceEditorTests/FindPanelTests.swift index 4ddacbc4c..1523a046b 100644 --- a/Tests/CodeEditSourceEditorTests/FindPanelTests.swift +++ b/Tests/CodeEditSourceEditorTests/FindPanelTests.swift @@ -236,4 +236,65 @@ struct FindPanelTests { viewModel.find() #expect(viewModel.findMatches.count == 3) } + + @Test func selectNextOccurrence() async throws { + target.textView.string = "test1 test2 test3" + + // Select first occurrence + target.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 4))], scrollToVisible: false) + + // Select next occurrence + viewModel.selectNextOccurrence() + + // Verify we have two selections + #expect(target.cursorPositions.count == 2) + #expect(target.cursorPositions[0].range == NSRange(location: 0, length: 4)) + #expect(target.cursorPositions[1].range == NSRange(location: 6, length: 4)) + } + + @Test func selectPreviousOccurrence() async throws { + target.textView.string = "test1 test2 test3" + + // Select last occurrence + target.setCursorPositions([CursorPosition(range: NSRange(location: 12, length: 4))], scrollToVisible: false) + + // Select previous occurrence + viewModel.selectPreviousOccurrence() + + // Verify we have two selections + #expect(target.cursorPositions.count == 2) + #expect(target.cursorPositions[0].range == NSRange(location: 12, length: 4)) + #expect(target.cursorPositions[1].range == NSRange(location: 6, length: 4)) + } + + @Test func selectNextOccurrenceWrapsAround() async throws { + target.textView.string = "test1 test2 test3" + + // Select last occurrence + target.setCursorPositions([CursorPosition(range: NSRange(location: 12, length: 4))], scrollToVisible: false) + + // Select next occurrence (should wrap to first) + viewModel.selectNextOccurrence() + + // Verify we have two selections + #expect(target.cursorPositions.count == 2) + #expect(target.cursorPositions[0].range == NSRange(location: 12, length: 4)) + #expect(target.cursorPositions[1].range == NSRange(location: 0, length: 4)) + } + + @Test func selectPreviousOccurrenceWrapsAround() async throws { + target.textView.string = "test1 test2 test3" + + // Select first occurrence + target.setCursorPositions([CursorPosition(range: NSRange(location: 0, length: 4))], scrollToVisible: false) + + // Select previous occurrence (should wrap to last) + viewModel.selectPreviousOccurrence() + + // Verify we have two selections + #expect(target.cursorPositions.count == 2) + #expect(target.cursorPositions[0].range == NSRange(location: 0, length: 4)) + #expect(target.cursorPositions[1].range == NSRange(location: 12, length: 4)) + } } + \ No newline at end of file From 2027b476f18b152ea09963fcfde1c5e541fd1e6d Mon Sep 17 00:00:00 2001 From: Austin Condiff Date: Tue, 3 Jun 2025 15:27:19 -0500 Subject: [PATCH 2/2] Fixed bezel notification showing at the wrong time when executing select previous occurrence. --- .../Find/ViewModel/FindPanelViewModel+Move.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift index 141659b97..eb4a503b1 100644 --- a/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift +++ b/Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Move.swift @@ -64,8 +64,9 @@ extension FindPanelViewModel { showWrapNotification(forwards: forwards, error: true, targetView: target.findPanelTargetView) return } - - if isAtLimit && wrapAround { + + // Show wrap notification when we're on the first occurrence and about to wrap to the last occurrence + if currentFindMatchIndex == findMatches.count - 1 && !forwards && wrapAround { showWrapNotification(forwards: forwards, error: false, targetView: target.findPanelTargetView) } }