Skip to content

Commit 309bacc

Browse files
committed
Adds Select Next Occurrence (⇧⌥⌘E) and Select Previous Occurrence (⇧⌥⌘E) commands.
1 parent 7830486 commit 309bacc

File tree

6 files changed

+363
-20
lines changed

6 files changed

+363
-20
lines changed

Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ extension TextViewController {
201201

202202
func handleCommand(event: NSEvent, modifierFlags: UInt) -> NSEvent? {
203203
let commandKey = NSEvent.ModifierFlags.command.rawValue
204+
let optionKey = NSEvent.ModifierFlags.option.rawValue
205+
let shiftKey = NSEvent.ModifierFlags.shift.rawValue
204206

205207
switch (modifierFlags, event.charactersIgnoringModifiers) {
206208
case (commandKey, "/"):
@@ -219,6 +221,12 @@ extension TextViewController {
219221
case (0, "\u{1b}"): // Escape key
220222
self.findViewController?.hideFindPanel()
221223
return nil
224+
case (commandKey | optionKey | shiftKey, "E"): // ⇧ ⌥ ⌘ E - uppercase letter because shiftKey is present
225+
selectPreviousOccurrence(nil)
226+
return nil
227+
case (commandKey | optionKey, "e"): // ⌥ ⌘ E
228+
selectNextOccurrence(nil)
229+
return nil
222230
case (_, _):
223231
return event
224232
}

Sources/CodeEditSourceEditor/Controller/TextViewController.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
2121
public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification")
2222

2323
weak var findViewController: FindViewController?
24+
var findPanelViewModel: FindPanelViewModel? {
25+
findViewController?.viewModel
26+
}
2427

2528
var scrollView: NSScrollView!
2629
var textView: TextView!
@@ -391,4 +394,51 @@ public class TextViewController: NSViewController { // swiftlint:disable:this ty
391394
}
392395
localEvenMonitor = nil
393396
}
397+
398+
// MARK: - Multiple Selection Commands
399+
400+
@objc func selectNextOccurrence(_ sender: Any?) {
401+
guard let findPanelViewModel = findPanelViewModel else { return }
402+
findPanelViewModel.selectNextOccurrence()
403+
}
404+
405+
@objc func selectPreviousOccurrence(_ sender: Any?) {
406+
guard let findPanelViewModel = findPanelViewModel else { return }
407+
findPanelViewModel.selectPreviousOccurrence()
408+
}
409+
410+
public override func viewDidLoad() {
411+
super.viewDidLoad()
412+
413+
// Initialize find view controller if not already set
414+
if findViewController == nil {
415+
let findVC = FindViewController(target: self, childView: view)
416+
addChild(findVC)
417+
view.addSubview(findVC.view)
418+
419+
// Set up constraints
420+
findVC.view.translatesAutoresizingMaskIntoConstraints = false
421+
NSLayoutConstraint.activate([
422+
findVC.view.topAnchor.constraint(equalTo: view.topAnchor),
423+
findVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
424+
findVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
425+
])
426+
427+
findViewController = findVC
428+
}
429+
}
394430
}
431+
432+
// MARK: - NSMenuItemValidation
433+
434+
extension TextViewController: NSMenuItemValidation {
435+
public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
436+
switch menuItem.action {
437+
case #selector(selectNextOccurrence(_:)), #selector(selectPreviousOccurrence(_:)):
438+
return textView.selectedRange.length > 0
439+
default:
440+
return true
441+
}
442+
}
443+
}
444+

Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Emphasis.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import CodeEditTextView
99

1010
extension FindPanelViewModel {
11-
func addMatchEmphases(flashCurrent: Bool) {
11+
func addMatchEmphases(flashCurrent: Bool, allowSelection: Bool = true) {
1212
guard let target = target, let emphasisManager = target.textView.emphasisManager else {
1313
return
1414
}
@@ -23,15 +23,15 @@ extension FindPanelViewModel {
2323
style: .standard,
2424
flash: flashCurrent && index == currentFindMatchIndex,
2525
inactive: index != currentFindMatchIndex,
26-
selectInDocument: index == currentFindMatchIndex
26+
selectInDocument: allowSelection && index == currentFindMatchIndex
2727
)
2828
}
2929

3030
// Add all emphases
3131
emphasisManager.addEmphases(emphases, for: EmphasisGroup.find)
3232
}
3333

34-
func flashCurrentMatch() {
34+
func flashCurrentMatch(allowSelection: Bool = true) {
3535
guard let target = target,
3636
let emphasisManager = target.textView.emphasisManager,
3737
let currentFindMatchIndex else {
@@ -50,7 +50,7 @@ extension FindPanelViewModel {
5050
style: .standard,
5151
flash: true,
5252
inactive: false,
53-
selectInDocument: true
53+
selectInDocument: allowSelection
5454
)
5555
)
5656

Sources/CodeEditSourceEditor/Find/ViewModel/FindPanelViewModel+Find.swift

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import Foundation
9+
import CodeEditTextView
910

1011
extension FindPanelViewModel {
1112
// MARK: - Find
@@ -64,8 +65,10 @@ extension FindPanelViewModel {
6465

6566
self.findMatches = matches.map(\.range)
6667

67-
// Find the nearest match to the current cursor position
68-
currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches)
68+
// Only set currentFindMatchIndex if we're not doing multiple selection
69+
if !isFocused {
70+
currentFindMatchIndex = getNearestEmphasisIndex(matchRanges: findMatches)
71+
}
6972

7073
// Only add emphasis layers if the find panel is focused
7174
if isFocused {
@@ -115,4 +118,184 @@ extension FindPanelViewModel {
115118
return bestIndex >= 0 ? bestIndex : nil
116119
}
117120

121+
// MARK: - Multiple Selection Support
122+
123+
/// Selects the next occurrence of the current selection while maintaining existing selections
124+
func selectNextOccurrence() {
125+
guard let target = target,
126+
let currentSelection = target.cursorPositions.first?.range else {
127+
return
128+
}
129+
130+
// Set find text to the current selection
131+
let selectedText = (target.textView.string as NSString).substring(with: currentSelection)
132+
133+
// Only update findText if it's different from the current selection
134+
if findText != selectedText {
135+
findText = selectedText
136+
// Clear existing matches since we're searching for something new
137+
findMatches = []
138+
currentFindMatchIndex = nil
139+
}
140+
141+
// Perform find if we haven't already
142+
if findMatches.isEmpty {
143+
find()
144+
}
145+
146+
// Find the next unselected match
147+
let selectedRanges = target.cursorPositions.map { $0.range }
148+
149+
// Find the index of the current selection
150+
if let currentIndex = findMatches.firstIndex(where: { $0.location == currentSelection.location }) {
151+
// Find the next unselected match
152+
var nextIndex = (currentIndex + 1) % findMatches.count
153+
var wrappedAround = false
154+
155+
while selectedRanges.contains(where: { $0.location == findMatches[nextIndex].location }) {
156+
nextIndex = (nextIndex + 1) % findMatches.count
157+
// If we've gone all the way around, break to avoid infinite loop
158+
if nextIndex == currentIndex {
159+
// If we've wrapped around and still haven't found an unselected match,
160+
// show the "no more matches" notification and flash the current match
161+
showWrapNotification(forwards: true, error: true, targetView: target.findPanelTargetView)
162+
if let currentIndex = currentFindMatchIndex {
163+
target.textView.emphasisManager?.addEmphases([
164+
Emphasis(
165+
range: findMatches[currentIndex],
166+
style: .standard,
167+
flash: true,
168+
inactive: false,
169+
selectInDocument: false
170+
)
171+
], for: EmphasisGroup.find)
172+
}
173+
return
174+
}
175+
// If we've wrapped around once, set the flag
176+
if nextIndex == 0 {
177+
wrappedAround = true
178+
}
179+
}
180+
181+
// If we wrapped around and wrapAround is false, show the "no more matches" notification
182+
if wrappedAround && !wrapAround {
183+
showWrapNotification(forwards: true, error: true, targetView: target.findPanelTargetView)
184+
if let currentIndex = currentFindMatchIndex {
185+
target.textView.emphasisManager?.addEmphases([
186+
Emphasis(
187+
range: findMatches[currentIndex],
188+
style: .standard,
189+
flash: true,
190+
inactive: false,
191+
selectInDocument: false
192+
)
193+
], for: EmphasisGroup.find)
194+
}
195+
return
196+
}
197+
198+
// If we wrapped around and wrapAround is true, show the wrap notification
199+
if wrappedAround {
200+
showWrapNotification(forwards: true, error: false, targetView: target.findPanelTargetView)
201+
}
202+
203+
currentFindMatchIndex = nextIndex
204+
} else {
205+
currentFindMatchIndex = nil
206+
}
207+
208+
// Use the existing moveMatch function with keepExistingSelections enabled
209+
moveMatch(forwards: true, keepExistingSelections: true)
210+
}
211+
212+
/// Selects the previous occurrence of the current selection while maintaining existing selections
213+
func selectPreviousOccurrence() {
214+
guard let target = target,
215+
let currentSelection = target.cursorPositions.first?.range else {
216+
return
217+
}
218+
219+
// Set find text to the current selection
220+
let selectedText = (target.textView.string as NSString).substring(with: currentSelection)
221+
222+
// Only update findText if it's different from the current selection
223+
if findText != selectedText {
224+
findText = selectedText
225+
// Clear existing matches since we're searching for something new
226+
findMatches = []
227+
currentFindMatchIndex = nil
228+
}
229+
230+
// Perform find if we haven't already
231+
if findMatches.isEmpty {
232+
find()
233+
}
234+
235+
// Find the previous unselected match
236+
let selectedRanges = target.cursorPositions.map { $0.range }
237+
238+
// Find the index of the current selection
239+
if let currentIndex = findMatches.firstIndex(where: { $0.location == currentSelection.location }) {
240+
// Find the previous unselected match
241+
var prevIndex = (currentIndex - 1 + findMatches.count) % findMatches.count
242+
var wrappedAround = false
243+
244+
while selectedRanges.contains(where: { $0.location == findMatches[prevIndex].location }) {
245+
prevIndex = (prevIndex - 1 + findMatches.count) % findMatches.count
246+
// If we've gone all the way around, break to avoid infinite loop
247+
if prevIndex == currentIndex {
248+
// If we've wrapped around and still haven't found an unselected match,
249+
// show the "no more matches" notification and flash the current match
250+
showWrapNotification(forwards: false, error: true, targetView: target.findPanelTargetView)
251+
if let currentIndex = currentFindMatchIndex {
252+
target.textView.emphasisManager?.addEmphases([
253+
Emphasis(
254+
range: findMatches[currentIndex],
255+
style: .standard,
256+
flash: true,
257+
inactive: false,
258+
selectInDocument: false
259+
)
260+
], for: EmphasisGroup.find)
261+
}
262+
return
263+
}
264+
// If we've wrapped around once, set the flag
265+
if prevIndex == findMatches.count - 1 {
266+
wrappedAround = true
267+
}
268+
}
269+
270+
// If we wrapped around and wrapAround is false, show the "no more matches" notification
271+
if wrappedAround && !wrapAround {
272+
showWrapNotification(forwards: false, error: true, targetView: target.findPanelTargetView)
273+
if let currentIndex = currentFindMatchIndex {
274+
target.textView.emphasisManager?.addEmphases([
275+
Emphasis(
276+
range: findMatches[currentIndex],
277+
style: .standard,
278+
flash: true,
279+
inactive: false,
280+
selectInDocument: false
281+
)
282+
], for: EmphasisGroup.find)
283+
}
284+
return
285+
}
286+
287+
// If we wrapped around and wrapAround is true, show the wrap notification
288+
if wrappedAround {
289+
showWrapNotification(forwards: false, error: false, targetView: target.findPanelTargetView)
290+
}
291+
292+
currentFindMatchIndex = prevIndex
293+
} else {
294+
currentFindMatchIndex = nil
295+
}
296+
297+
// Use the existing moveMatch function with keepExistingSelections enabled
298+
moveMatch(forwards: false, keepExistingSelections: true)
299+
}
300+
118301
}

0 commit comments

Comments
 (0)