Skip to content

Added Select Next/Previous Occurrence commands #330

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ extension TextViewController {

func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
let commandKey = NSEvent.ModifierFlags.command.rawValue
let commandOptionKey = NSEvent.ModifierFlags.command.union(.option).rawValue
let optionKey = NSEvent.ModifierFlags.option.rawValue
let shiftKey = NSEvent.ModifierFlags.shift.rawValue

switch (modifierFlags, event.charactersIgnoringModifiers) {
case (commandKey, "/"):
Expand All @@ -210,13 +211,13 @@ extension TextViewController {
case (commandKey, "["):
handleIndent(inwards: true)
return nil
case (commandOptionKey, "["):
case (commandKey | optionKey, "["):
moveLinesUp()
return nil
case (commandKey, "]"):
handleIndent()
return nil
case (commandOptionKey, "]"):
case (commandKey | optionKey, "]"):
moveLinesDown()
return nil
case (commandKey, "f"):
Expand All @@ -226,6 +227,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
}
Expand Down
50 changes: 50 additions & 0 deletions Sources/CodeEditSourceEditor/Controller/TextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -23,15 +23,15 @@ extension FindPanelViewModel {
style: .standard,
flash: flashCurrent && index == currentFindMatchIndex,
inactive: index != currentFindMatchIndex,
selectInDocument: index == currentFindMatchIndex
selectInDocument: allowSelection && index == currentFindMatchIndex
)
}

// Add all emphases
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 {
Expand All @@ -50,7 +50,7 @@ extension FindPanelViewModel {
style: .standard,
flash: true,
inactive: false,
selectInDocument: true
selectInDocument: allowSelection
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Foundation
import CodeEditTextView

extension FindPanelViewModel {
// MARK: - Find
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

}
Loading