mirror of
https://github.com/coteditor/CotEditor.git
synced 2024-09-11 11:25:57 +03:00
Async perform text search
This commit is contained in:
parent
6fa0142fb0
commit
6403bc71b0
@ -13,6 +13,7 @@ Change Log
|
||||
### Improvements
|
||||
|
||||
- Optimize the performance for editor splitting.
|
||||
- Avoid sluggishness by incremental search in large documents.
|
||||
- Update Swift syntax style to add keywords.
|
||||
- [trivial] Update French localization.
|
||||
|
||||
|
@ -187,7 +187,8 @@ final class TextFind {
|
||||
/// - isWrap: Whether the search wraps around.
|
||||
/// - includingCurrentSelection: Whether includes the current selection to search.
|
||||
/// - Returns:An FindResult object.
|
||||
func find(forward: Bool, isWrap: Bool, includingSelection: Bool = false) -> FindResult {
|
||||
/// - Throws: `CancellationError`
|
||||
func find(forward: Bool, isWrap: Bool, includingSelection: Bool = false) throws -> FindResult {
|
||||
|
||||
assert(forward || !includingSelection)
|
||||
|
||||
@ -202,13 +203,23 @@ final class TextFind {
|
||||
|
||||
var forwardMatches: [NSRange] = [] // matches after the start location
|
||||
let forwardRange = NSRange(startLocation..<self.string.length)
|
||||
self.enumerateMatchs(in: forwardRange) { (matchedRange, _, _) in
|
||||
self.enumerateMatchs(in: forwardRange) { (matchedRange, _, stop) in
|
||||
if Task.isCancelled {
|
||||
stop = true
|
||||
return
|
||||
}
|
||||
forwardMatches.append(matchedRange)
|
||||
}
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
var wrappedMatches: [NSRange] = [] // matches before the start location
|
||||
var intersectionMatches: [NSRange] = [] // matches including the start location
|
||||
self.enumerateMatchs(in: self.string.range) { (matchedRange, _, stop) in
|
||||
if Task.isCancelled {
|
||||
stop = true
|
||||
return
|
||||
}
|
||||
if matchedRange.location >= startLocation {
|
||||
stop = true
|
||||
return
|
||||
@ -220,6 +231,8 @@ final class TextFind {
|
||||
}
|
||||
}
|
||||
|
||||
try Task.checkCancellation()
|
||||
|
||||
var foundRange = forward ? forwardMatches.first : wrappedMatches.last
|
||||
|
||||
// wrap search
|
||||
|
@ -72,6 +72,7 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
private var searchTask: Task<Void, Error>?
|
||||
private var applicationActivationObserver: AnyCancellable?
|
||||
private var highlightObserver: AnyCancellable?
|
||||
|
||||
@ -154,7 +155,10 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
/// - Returns: The number of found.
|
||||
func incrementalSearch() {
|
||||
|
||||
self.find(forward: true, marksAllMatches: true, isIncremental: true)
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task(priority: .userInitiated) {
|
||||
try await self.find(forward: true, marksAllMatches: true, isIncremental: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -188,14 +192,20 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
// find backwards if Shift key pressed
|
||||
let isShiftPressed = NSEvent.modifierFlags.contains(.shift)
|
||||
|
||||
self.find(forward: !isShiftPressed)
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task(priority: .userInitiated) {
|
||||
try await self.find(forward: !isShiftPressed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Find previous matched string.
|
||||
@IBAction func findPrevious(_ sender: Any?) {
|
||||
|
||||
self.find(forward: false)
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task(priority: .userInitiated) {
|
||||
try await self.find(forward: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -268,7 +278,11 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
@IBAction func replaceAndFind(_ sender: Any?) {
|
||||
|
||||
self.replace()
|
||||
self.find(forward: true)
|
||||
|
||||
self.searchTask?.cancel()
|
||||
self.searchTask = Task(priority: .userInitiated) {
|
||||
try await self.find(forward: true)
|
||||
}
|
||||
|
||||
UserDefaults.standard.appendHistory(self.findString, forKey: .findHistory)
|
||||
UserDefaults.standard.appendHistory(self.replacementString, forKey: .replaceHistory)
|
||||
@ -388,7 +402,7 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
///
|
||||
/// - Parameter forEditing: When true, perform only when the textView is editable.
|
||||
/// - Returns: The target textView and a TextFind object.
|
||||
private func prepareTextFind(forEditing: Bool) -> (NSTextView, TextFind)? {
|
||||
@MainActor private func prepareTextFind(forEditing: Bool) -> (NSTextView, TextFind)? {
|
||||
|
||||
guard
|
||||
let textView = self.client,
|
||||
@ -435,56 +449,58 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
/// - forward: The flag whether finds forward or backward.
|
||||
/// - marksAllMatches: Whether marks all matches in the editor.
|
||||
/// - isIncremental: Whether is the incremental search.
|
||||
private func find(forward: Bool, marksAllMatches: Bool = false, isIncremental: Bool = false) {
|
||||
private nonisolated func find(forward: Bool, marksAllMatches: Bool = false, isIncremental: Bool = false) async throws {
|
||||
|
||||
assert(forward || !isIncremental)
|
||||
|
||||
guard let (textView, textFind) = self.prepareTextFind(forEditing: false) else { return }
|
||||
guard let (textView, textFind) = await self.prepareTextFind(forEditing: false) else { return }
|
||||
|
||||
let result = textFind.find(forward: forward, isWrap: UserDefaults.standard[.findIsWrap], includingSelection: isIncremental)
|
||||
let result = try textFind.find(forward: forward, isWrap: UserDefaults.standard[.findIsWrap], includingSelection: isIncremental)
|
||||
|
||||
// mark all matches
|
||||
if marksAllMatches, let layoutManager = textView.layoutManager {
|
||||
layoutManager.groupTemporaryAttributesUpdate(in: textView.string.nsRange) {
|
||||
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: textView.string.nsRange)
|
||||
for range in result.ranges {
|
||||
layoutManager.addTemporaryAttribute(.backgroundColor, value: NSColor.unemphasizedSelectedTextBackgroundColor, forCharacterRange: range)
|
||||
}
|
||||
}
|
||||
|
||||
// unmark either when the client view resigned the key window or when the Find panel closed
|
||||
self.highlightObserver = NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)
|
||||
.sink { [weak self, weak textView] _ in
|
||||
textView?.unhighlight()
|
||||
self?.highlightObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
// found feedback
|
||||
if let range = result.range {
|
||||
textView.select(range: range)
|
||||
textView.showFindIndicator(for: range)
|
||||
|
||||
if result.wrapped {
|
||||
if let view = textView.enclosingScrollView?.superview {
|
||||
let hudController = HUDController.instantiate(storyboard: "HUDView")
|
||||
hudController.symbol = .wrap(reversed: !forward)
|
||||
hudController.show(in: view)
|
||||
Task { @MainActor in
|
||||
// mark all matches
|
||||
if marksAllMatches, let layoutManager = textView.layoutManager {
|
||||
layoutManager.groupTemporaryAttributesUpdate(in: textView.string.nsRange) {
|
||||
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: textView.string.nsRange)
|
||||
for range in result.ranges {
|
||||
layoutManager.addTemporaryAttribute(.backgroundColor, value: NSColor.unemphasizedSelectedTextBackgroundColor, forCharacterRange: range)
|
||||
}
|
||||
}
|
||||
|
||||
if let window = NSApp.mainWindow {
|
||||
NSAccessibility.post(element: window, notification: .announcementRequested,
|
||||
userInfo: [.announcement: "Search wrapped.".localized])
|
||||
}
|
||||
// unmark either when the client view resigned the key window or when the Find panel closed
|
||||
self.highlightObserver = NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)
|
||||
.sink { [weak self, weak textView] _ in
|
||||
textView?.unhighlight()
|
||||
self?.highlightObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
// found feedback
|
||||
if let range = result.range {
|
||||
textView.select(range: range)
|
||||
textView.showFindIndicator(for: range)
|
||||
|
||||
if result.wrapped {
|
||||
if let view = textView.enclosingScrollView?.superview {
|
||||
let hudController = HUDController.instantiate(storyboard: "HUDView")
|
||||
hudController.symbol = .wrap(reversed: !forward)
|
||||
hudController.show(in: view)
|
||||
}
|
||||
|
||||
if let window = NSApp.mainWindow {
|
||||
NSAccessibility.post(element: window, notification: .announcementRequested,
|
||||
userInfo: [.announcement: "Search wrapped.".localized])
|
||||
}
|
||||
}
|
||||
} else if !isIncremental {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
self.delegate?.textFinder(self, didFind: result.ranges.count, textView: textView)
|
||||
|
||||
if !isIncremental {
|
||||
UserDefaults.standard.appendHistory(self.findString, forKey: .findHistory)
|
||||
}
|
||||
} else if !isIncremental {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
self.delegate?.textFinder(self, didFind: result.ranges.count, textView: textView)
|
||||
|
||||
if !isIncremental {
|
||||
UserDefaults.standard.appendHistory(self.findString, forKey: .findHistory)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,12 +58,12 @@ final class TextFindTests: XCTestCase {
|
||||
|
||||
textFind = try TextFind(for: text, findString: findString, mode: .textual(options: [], fullWord: false))
|
||||
|
||||
result = textFind.find(forward: true, isWrap: false)
|
||||
result = try textFind.find(forward: true, isWrap: false)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertEqual(result.range, NSRange(location: 0, length: 3))
|
||||
XCTAssertFalse(result.wrapped)
|
||||
|
||||
result = textFind.find(forward: false, isWrap: false)
|
||||
result = try textFind.find(forward: false, isWrap: false)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertNil(result.range)
|
||||
XCTAssertFalse(result.wrapped)
|
||||
@ -71,12 +71,12 @@ final class TextFindTests: XCTestCase {
|
||||
|
||||
textFind = try TextFind(for: text, findString: findString, mode: .textual(options: [], fullWord: false), selectedRanges: [NSRange(location: 1, length: 0)])
|
||||
|
||||
result = textFind.find(forward: true, isWrap: true)
|
||||
result = try textFind.find(forward: true, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertEqual(result.range, NSRange(location: 8, length: 3))
|
||||
XCTAssertFalse(result.wrapped)
|
||||
|
||||
result = textFind.find(forward: false, isWrap: true)
|
||||
result = try textFind.find(forward: false, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertEqual(result.range, NSRange(location: 8, length: 3))
|
||||
XCTAssertTrue(result.wrapped)
|
||||
@ -84,7 +84,7 @@ final class TextFindTests: XCTestCase {
|
||||
|
||||
textFind = try TextFind(for: text, findString: findString, mode: .textual(options: .caseInsensitive, fullWord: false), selectedRanges: [NSRange(location: 1, length: 0)])
|
||||
|
||||
result = textFind.find(forward: false, isWrap: true)
|
||||
result = try textFind.find(forward: false, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 3)
|
||||
XCTAssertEqual(result.range, NSRange(location: 16, length: 3))
|
||||
XCTAssertTrue(result.wrapped)
|
||||
@ -98,25 +98,25 @@ final class TextFindTests: XCTestCase {
|
||||
|
||||
textFind = try TextFind(for: "apples apple Apple", findString: "apple",
|
||||
mode: .textual(options: .caseInsensitive, fullWord: true))
|
||||
result = textFind.find(forward: true, isWrap: true)
|
||||
result = try textFind.find(forward: true, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertEqual(result.range, NSRange(location: 7, length: 5))
|
||||
|
||||
textFind = try TextFind(for: "apples apple Apple", findString: "apple",
|
||||
mode: .textual(options: [.caseInsensitive, .literal], fullWord: true))
|
||||
result = textFind.find(forward: true, isWrap: true)
|
||||
result = try textFind.find(forward: true, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertEqual(result.range, NSRange(location: 7, length: 5))
|
||||
|
||||
textFind = try TextFind(for: "Apfel Äpfel Äpfelchen", findString: "Äpfel",
|
||||
mode: .textual(options: .diacriticInsensitive, fullWord: true))
|
||||
result = textFind.find(forward: true, isWrap: true)
|
||||
result = try textFind.find(forward: true, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertEqual(result.range, NSRange(location: 0, length: 5))
|
||||
|
||||
textFind = try TextFind(for: "イヌら イヌ イヌ", findString: "イヌ",
|
||||
mode: .textual(options: .widthInsensitive, fullWord: true))
|
||||
result = textFind.find(forward: true, isWrap: true)
|
||||
result = try textFind.find(forward: true, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 2)
|
||||
XCTAssertEqual(result.range, NSRange(location: 4, length: 2))
|
||||
}
|
||||
@ -129,7 +129,7 @@ final class TextFindTests: XCTestCase {
|
||||
let mode: TextFind.Mode = .regularExpression(options: .caseInsensitive, unescapesReplacement: true)
|
||||
|
||||
let textFind = try TextFind(for: text, findString: findString, mode: mode)
|
||||
let result = textFind.find(forward: false, isWrap: true)
|
||||
let result = try textFind.find(forward: false, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 1)
|
||||
|
||||
// wrong pattern with raw NSRegularExpression
|
||||
@ -160,12 +160,12 @@ final class TextFindTests: XCTestCase {
|
||||
|
||||
textFind = try TextFind(for: "abcdefg abcdefg ABCDEFG", findString: findString, mode: mode, selectedRanges: [NSRange(location: 1, length: 1)])
|
||||
|
||||
result = textFind.find(forward: true, isWrap: true)
|
||||
result = try textFind.find(forward: true, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 3)
|
||||
XCTAssertEqual(result.range, NSRange(location: 9, length: 2))
|
||||
XCTAssertFalse(result.wrapped)
|
||||
|
||||
result = textFind.find(forward: false, isWrap: true)
|
||||
result = try textFind.find(forward: false, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 3)
|
||||
XCTAssertEqual(result.range, NSRange(location: 17, length: 2))
|
||||
XCTAssertTrue(result.wrapped)
|
||||
@ -173,12 +173,12 @@ final class TextFindTests: XCTestCase {
|
||||
|
||||
textFind = try TextFind(for: "ABCDEFG", findString: findString, mode: mode, selectedRanges: [NSRange(location: 1, length: 1)])
|
||||
|
||||
result = textFind.find(forward: true, isWrap: true)
|
||||
result = try textFind.find(forward: true, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 1)
|
||||
XCTAssertEqual(result.range, NSRange(location: 1, length: 2))
|
||||
XCTAssertTrue(result.wrapped)
|
||||
|
||||
result = textFind.find(forward: false, isWrap: true)
|
||||
result = try textFind.find(forward: false, isWrap: true)
|
||||
XCTAssertEqual(result.ranges.count, 1)
|
||||
XCTAssertEqual(result.range, NSRange(location: 1, length: 2))
|
||||
XCTAssertTrue(result.wrapped)
|
||||
|
Loading…
Reference in New Issue
Block a user