mirror of
https://github.com/coteditor/CotEditor.git
synced 2024-10-26 19:10:11 +03:00
Improve Replace All progress
This commit is contained in:
parent
ca2ab5e619
commit
d11eecdce8
@ -7,7 +7,7 @@ Change Log
|
||||
|
||||
### Improvements
|
||||
|
||||
- Optimize large find/replace task performance.
|
||||
- Optimize performance of find/replace with large documents.
|
||||
- Display the concrete progress of the find/replace task in the progress dialog.
|
||||
- Draw link underlines on the left side by the vertical text orientation.
|
||||
- Update the Unicode block name list for the character inspector from Unicode 14.0.0 to Unicode 15.0.0.
|
||||
|
@ -221,12 +221,3 @@ final class FindPanelContentViewController: NSSplitViewController, TextFinderDel
|
||||
self.splitView.needsDisplay = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension NSTextView {
|
||||
|
||||
func unhighlight() {
|
||||
|
||||
self.layoutManager?.removeTemporaryAttribute(.backgroundColor, forCharacterRange: self.string.nsRange)
|
||||
}
|
||||
}
|
||||
|
@ -139,18 +139,13 @@ extension MultipleReplacement {
|
||||
guard let textFind = try? TextFind(for: result.string, findString: replacement.findString, mode: mode, inSelection: inSelection, selectedRanges: findRanges) else { continue }
|
||||
|
||||
// process replacement
|
||||
let (replacementItems, selectedRanges) = textFind.replaceAll(with: replacement.replacementString) { (status, _, stop) in
|
||||
let (replacementItems, selectedRanges) = textFind.replaceAll(with: replacement.replacementString) { (_, stop) in
|
||||
guard progress?.isCancelled != true else {
|
||||
stop = true
|
||||
return
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .found:
|
||||
break
|
||||
case .replaced:
|
||||
progress?.count += 1
|
||||
}
|
||||
progress?.count += 1
|
||||
}
|
||||
|
||||
// finish if cancelled
|
||||
|
@ -42,13 +42,6 @@ final class TextFind {
|
||||
}
|
||||
|
||||
|
||||
enum ReplacingStatus {
|
||||
|
||||
case found
|
||||
case replaced
|
||||
}
|
||||
|
||||
|
||||
enum `Error`: LocalizedError {
|
||||
|
||||
case regularExpression(reason: String)
|
||||
@ -280,7 +273,7 @@ final class TextFind {
|
||||
/// - Parameters:
|
||||
/// - block: The Block enumerates the matches.
|
||||
/// - matches: The array of matches including group matches.
|
||||
/// - stop: The Block can set the value to true to stop further processing of the array.
|
||||
/// - stop: The `block` can set the value to true to stop further processing.
|
||||
func findAll(using block: (_ matches: [NSRange], _ stop: inout Bool) -> Void) {
|
||||
|
||||
for range in self.scopeRanges {
|
||||
@ -302,23 +295,24 @@ final class TextFind {
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - replacementString: The string with which to replace.
|
||||
/// - block: The Block enumerates the matches.
|
||||
/// - status: The current state of the replacing progress.
|
||||
/// - block: The block notifying the replacement progress.
|
||||
/// - range: The matched range.
|
||||
/// - stop: The Block can set the value to true to stop further processing of the array.
|
||||
/// - stop: The `block` can set the value to true to stop further processing.
|
||||
/// - Returns:
|
||||
/// - replacementItems: ReplacementItem per selectedRange.
|
||||
/// - selectedRanges: New selections for textView only if the replacement is performed within selection. Otherwise, nil.
|
||||
func replaceAll(with replacementString: String, using block: @escaping (_ status: ReplacingStatus, _ range: NSRange, _ stop: inout Bool) -> Void) -> (replacementItems: [ReplacementItem], selectedRanges: [NSRange]?) {
|
||||
func replaceAll(with replacementString: String, using block: @escaping (_ range: NSRange, _ stop: inout Bool) -> Void) -> (replacementItems: [ReplacementItem], selectedRanges: [NSRange]?) {
|
||||
|
||||
let replacementString = self.replacementString(from: replacementString)
|
||||
var replacementItems: [ReplacementItem] = []
|
||||
var selectedRanges: [NSRange] = []
|
||||
var ioStop = false
|
||||
|
||||
for scopeRange in self.scopeRanges {
|
||||
var items: [ReplacementItem] = []
|
||||
let scopeString = NSMutableString(string: (self.string as NSString).substring(with: scopeRange))
|
||||
var offset = 0
|
||||
var ioStop = false
|
||||
|
||||
// replace string
|
||||
self.enumerateMatchs(in: scopeRange) { (matchedRange, match, stop) in
|
||||
let replacedString: String = {
|
||||
guard let match = match, let regex = match.regularExpression else { return replacementString }
|
||||
@ -326,41 +320,28 @@ final class TextFind {
|
||||
return regex.replacementString(for: match, in: self.string, offset: 0, template: replacementString)
|
||||
}()
|
||||
|
||||
items.append(ReplacementItem(string: replacedString, range: matchedRange))
|
||||
let localRange = matchedRange.shifted(by: -scopeRange.location - offset)
|
||||
scopeString.replaceCharacters(in: localRange, with: replacedString)
|
||||
offset += matchedRange.length - replacedString.length
|
||||
|
||||
block(.found, matchedRange, &ioStop)
|
||||
block(matchedRange, &ioStop)
|
||||
stop = ioStop
|
||||
}
|
||||
|
||||
if ioStop { break }
|
||||
guard !ioStop else { break }
|
||||
|
||||
let length: Int
|
||||
if items.isEmpty {
|
||||
length = scopeRange.length
|
||||
} else {
|
||||
// build replacementString
|
||||
let replacedString = NSMutableString(string: (self.string as NSString).substring(with: scopeRange))
|
||||
for item in items.reversed() {
|
||||
var ioStop = false
|
||||
block(.replaced, item.range, &ioStop)
|
||||
if ioStop { break }
|
||||
|
||||
// -> Do not convert to Range<Index>. It can fail when the range is smaller than String.Character.
|
||||
let substringRange = item.range.shifted(by: -scopeRange.location)
|
||||
replacedString.replaceCharacters(in: substringRange, with: item.string)
|
||||
}
|
||||
replacementItems.append(ReplacementItem(string: replacedString.copy() as! String, range: scopeRange))
|
||||
length = replacedString.length
|
||||
// append only when actually modified
|
||||
if (self.string as NSString).substring(with: scopeRange) != scopeString as String {
|
||||
replacementItems.append(ReplacementItem(string: scopeString.copy() as! String, range: scopeRange))
|
||||
}
|
||||
|
||||
if ioStop { break }
|
||||
|
||||
// build selectedRange
|
||||
let locationDelta = zip(selectedRanges, self.selectedRanges)
|
||||
.map { $0.0.length - $0.1.length }
|
||||
.reduce(scopeRange.location, +)
|
||||
let selectedRange = NSRange(location: locationDelta, length: length)
|
||||
selectedRanges.append(selectedRange)
|
||||
if self.inSelection {
|
||||
let location = zip(selectedRanges, self.selectedRanges)
|
||||
.map { $0.0.length - $0.1.length }
|
||||
.reduce(scopeRange.location, +)
|
||||
selectedRanges.append(NSRange(location: location, length: scopeString.length))
|
||||
}
|
||||
}
|
||||
|
||||
return (replacementItems, self.inSelection ? selectedRanges : nil)
|
||||
@ -370,7 +351,11 @@ final class TextFind {
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// unescape given string for replacement string only if needed
|
||||
/// Unescape the given string for replacement string as needed.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - string: The string to use as the replacement template.
|
||||
/// - Returns: Unescaped replacement string.
|
||||
private func replacementString(from string: String) -> String {
|
||||
|
||||
switch self.mode {
|
||||
@ -382,7 +367,11 @@ final class TextFind {
|
||||
}
|
||||
|
||||
|
||||
/// chack if the given range is a range of whole word
|
||||
/// Chack if the given range is a range of whole word.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - range: The charater range to test.
|
||||
/// - Returns: Whether the substring of the given range is full word.
|
||||
private func isFullWord(range: NSRange) -> Bool {
|
||||
|
||||
self.fullWordChecker.firstMatch(in: self.string, options: .withTransparentBounds, range: range) != nil
|
||||
@ -411,7 +400,11 @@ final class TextFind {
|
||||
}
|
||||
|
||||
|
||||
/// enumerate matchs in string using current settings
|
||||
/// Enumerate matchs in string using current settings.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - range: The range of the string to search.
|
||||
/// - block: The block that enumerates the matches.
|
||||
private func enumerateMatchs(in range: NSRange, using block: (_ matchedRange: NSRange, _ match: NSTextCheckingResult?, _ stop: inout Bool) -> Void) {
|
||||
|
||||
switch self.mode {
|
||||
@ -423,7 +416,11 @@ final class TextFind {
|
||||
}
|
||||
|
||||
|
||||
/// enumerate matchs in string using textual search
|
||||
/// Enumerate matchs in string using textual search
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - range: The range of the string to search.
|
||||
/// - block: The block that enumerates the matches.
|
||||
private func enumerateTextualMatchs(in range: NSRange, options: String.CompareOptions, fullWord: Bool, using block: (_ matchedRange: NSRange, _ match: NSTextCheckingResult?, _ stop: inout Bool) -> Void) {
|
||||
|
||||
guard !self.string.isEmpty else { return }
|
||||
@ -449,7 +446,11 @@ final class TextFind {
|
||||
}
|
||||
|
||||
|
||||
/// enumerate matchs in string using regular expression
|
||||
/// Enumerate matchs in string using regular expression.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - range: The range of the string to search.
|
||||
/// - block: The block that enumerates the matches.
|
||||
private func enumerateRegularExpressionMatchs(in range: NSRange, using block: (_ matchedRange: NSRange, _ match: NSTextCheckingResult?, _ stop: inout Bool) -> Void) {
|
||||
|
||||
let string = self.string
|
||||
|
@ -127,13 +127,13 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
#selector(useSelectionForReplace(_:)), // replacement string accepts empty string
|
||||
#selector(centerSelectionInVisibleArea(_:)):
|
||||
return self.client != nil
|
||||
|
||||
|
||||
case #selector(useSelectionForFind(_:)):
|
||||
return self.selectedString != nil
|
||||
|
||||
|
||||
case nil:
|
||||
return false
|
||||
|
||||
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@ -225,7 +225,7 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
guard let (textView, textFind) = self.prepareTextFind(forEditing: false) else { return }
|
||||
|
||||
var matchedRanges: [NSRange] = []
|
||||
textFind.findAll { (matches: [NSRange], _) in
|
||||
textFind.findAll { (matches, _) in
|
||||
matchedRanges.append(matches[0])
|
||||
}
|
||||
|
||||
@ -240,25 +240,25 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
/// Find all matched strings and show results in a table.
|
||||
@IBAction func findAll(_ sender: Any?) {
|
||||
|
||||
self.findAll(showsList: true, actionName: "Find All")
|
||||
Task {
|
||||
await self.findAll(showsList: true, actionName: "Find All")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Highlight all matched strings.
|
||||
@IBAction func highlight(_ sender: Any?) {
|
||||
|
||||
self.findAll(showsList: false, actionName: "Highlight All")
|
||||
Task {
|
||||
await self.findAll(showsList: false, actionName: "Highlight All")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Remove all of current highlights in the frontmost textView.
|
||||
@IBAction func unhighlight(_ sender: Any?) {
|
||||
|
||||
guard let textView = self.client else { return }
|
||||
|
||||
let range = textView.string.nsRange
|
||||
|
||||
textView.layoutManager?.removeTemporaryAttribute(.backgroundColor, forCharacterRange: range)
|
||||
self.client?.unhighlight()
|
||||
}
|
||||
|
||||
|
||||
@ -294,67 +294,9 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
/// Replace all matched strings with given string.
|
||||
@IBAction func replaceAll(_ sender: Any?) {
|
||||
|
||||
guard let (textView, textFind) = self.prepareTextFind(forEditing: true) else { return }
|
||||
|
||||
textView.isEditable = false
|
||||
|
||||
let replacementString = self.replacementString
|
||||
|
||||
// setup progress sheet
|
||||
let progress = FindProgress(scope: textFind.scopeRange)
|
||||
let indicatorView = FindProgressView("Replace All", progress: progress, unit: .replacement)
|
||||
let indicator = NSHostingController(rootView: indicatorView)
|
||||
indicator.rootView.parent = indicator
|
||||
textView.viewControllerForSheet?.presentAsSheet(indicator)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let (replacementItems, selectedRanges) = textFind.replaceAll(with: replacementString) { (status, range, stop) in
|
||||
guard !progress.isCancelled else {
|
||||
stop = true
|
||||
return
|
||||
}
|
||||
|
||||
switch status {
|
||||
case .found:
|
||||
break
|
||||
case .replaced:
|
||||
progress.completedUnit = range.upperBound
|
||||
progress.count += 1
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
textView.isEditable = true
|
||||
|
||||
guard !progress.isCancelled else { return }
|
||||
|
||||
if !replacementItems.isEmpty {
|
||||
let replacementStrings = replacementItems.map(\.string)
|
||||
let replacementRanges = replacementItems.map(\.range)
|
||||
|
||||
// apply found strings to the text view
|
||||
textView.replace(with: replacementStrings, ranges: replacementRanges, selectedRanges: selectedRanges,
|
||||
actionName: "Replace All".localized)
|
||||
}
|
||||
|
||||
if replacementItems.isEmpty {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
progress.isFinished = true
|
||||
|
||||
if let panel = self.findPanelController.window, panel.isVisible {
|
||||
panel.makeKey()
|
||||
}
|
||||
|
||||
self.delegate?.textFinder(self, didReplace: progress.count, textView: textView)
|
||||
}
|
||||
Task {
|
||||
await self.replaceAll()
|
||||
}
|
||||
|
||||
UserDefaults.standard.appendHistory(self.findString, forKey: .findHistory)
|
||||
UserDefaults.standard.appendHistory(self.replacementString, forKey: .replaceHistory)
|
||||
}
|
||||
|
||||
|
||||
@ -449,75 +391,77 @@ 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 nonisolated func find(forward: Bool, marksAllMatches: Bool = false, isIncremental: Bool = false) async throws {
|
||||
/// - Throws: `CancellationError`
|
||||
@MainActor private func find(forward: Bool, marksAllMatches: Bool = false, isIncremental: Bool = false) async throws {
|
||||
|
||||
assert(forward || !isIncremental)
|
||||
|
||||
guard let (textView, textFind) = await self.prepareTextFind(forEditing: false) else { return }
|
||||
guard let (textView, textFind) = self.prepareTextFind(forEditing: false) else { return }
|
||||
|
||||
let result = try textFind.find(forward: forward, isWrap: UserDefaults.standard[.findIsWrap], includingSelection: isIncremental)
|
||||
// find in background thread
|
||||
let result = try await Task.detached(priority: .userInitiated) {
|
||||
try textFind.find(forward: forward, isWrap: UserDefaults.standard[.findIsWrap], includingSelection: isIncremental)
|
||||
}.value
|
||||
|
||||
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)
|
||||
}
|
||||
// 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 hudView = NSHostingView(rootView: HUDView(symbol: .wrap, flipped: !forward))
|
||||
hudView.rootView.parent = hudView
|
||||
hudView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// remove previous HUD if any
|
||||
for subview in view.subviews where subview is NSHostingView<HUDView> {
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
|
||||
view.addSubview(hudView)
|
||||
hudView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
|
||||
hudView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
|
||||
hudView.layout()
|
||||
// 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 hudView = NSHostingView(rootView: HUDView(symbol: .wrap, flipped: !forward))
|
||||
hudView.rootView.parent = hudView
|
||||
hudView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// remove previous HUD if any
|
||||
for subview in view.subviews where subview is NSHostingView<HUDView> {
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
|
||||
if let window = NSApp.mainWindow {
|
||||
NSAccessibility.post(element: window, notification: .announcementRequested,
|
||||
userInfo: [.announcement: "Search wrapped.".localized])
|
||||
}
|
||||
view.addSubview(hudView)
|
||||
hudView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
|
||||
hudView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
|
||||
hudView.layout()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Replace matched string in selection with replacementString.
|
||||
@discardableResult
|
||||
private func replace() -> Bool {
|
||||
@MainActor private func replace() -> Bool {
|
||||
|
||||
guard
|
||||
let (textView, textFind) = self.prepareTextFind(forEditing: true),
|
||||
@ -537,7 +481,7 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
/// - Parameters:
|
||||
/// - showsList: Whether shows the result view when finished.
|
||||
/// - actionName: The name of the action to display in the progress sheet.
|
||||
private func findAll(showsList: Bool, actionName: LocalizedStringKey) {
|
||||
@MainActor private func findAll(showsList: Bool, actionName: LocalizedStringKey) async {
|
||||
|
||||
guard let (textView, textFind) = self.prepareTextFind(forEditing: false) else { return }
|
||||
|
||||
@ -553,9 +497,7 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
indicator.rootView.parent = indicator
|
||||
textView.viewControllerForSheet?.presentAsSheet(indicator)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let (highlights, results) = await Task.detached(priority: .userInitiated) {
|
||||
var highlights: [ItemRange<NSColor>] = []
|
||||
var results: [TextFindResult] = [] // not used if showsList is false
|
||||
|
||||
@ -582,10 +524,9 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
let lineString = (textFind.string as NSString).substring(with: lineRange)
|
||||
let attrLineString = NSMutableAttributedString(string: lineString)
|
||||
for (index, range) in matches.enumerated() where !range.isEmpty {
|
||||
let color = highlightColors[index]
|
||||
let inlineRange = range.shifted(by: -lineRange.location)
|
||||
|
||||
attrLineString.addAttribute(.backgroundColor, value: color, range: inlineRange)
|
||||
attrLineString.addAttribute(.backgroundColor,
|
||||
value: highlightColors[index],
|
||||
range: range.shifted(by: -lineRange.location))
|
||||
}
|
||||
|
||||
// calculate inline range
|
||||
@ -598,40 +539,107 @@ final class TextFinder: NSResponder, NSMenuItemValidation {
|
||||
progress.count += 1
|
||||
}
|
||||
|
||||
await MainActor.run { [highlights, results] in
|
||||
textView.isEditable = true
|
||||
|
||||
guard !progress.isCancelled else { return }
|
||||
|
||||
// highlight
|
||||
if let layoutManager = textView.layoutManager {
|
||||
let wholeRange = textFind.string.nsRange
|
||||
layoutManager.groupTemporaryAttributesUpdate(in: wholeRange) {
|
||||
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: wholeRange)
|
||||
for highlight in highlights {
|
||||
layoutManager.addTemporaryAttribute(.backgroundColor, value: highlight.item, forCharacterRange: highlight.range)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if highlights.isEmpty {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
progress.isFinished = true
|
||||
|
||||
if showsList {
|
||||
self.delegate?.textFinder(self, didFinishFindingAll: textFind.findString, results: results, textView: textView)
|
||||
}
|
||||
|
||||
if !results.isEmpty, let panel = self.findPanelController.window, panel.isVisible {
|
||||
panel.makeKey()
|
||||
return (highlights, results)
|
||||
}.value
|
||||
|
||||
textView.isEditable = true
|
||||
|
||||
guard !progress.isCancelled else { return }
|
||||
|
||||
// highlight
|
||||
if let layoutManager = textView.layoutManager {
|
||||
let wholeRange = textFind.string.nsRange
|
||||
layoutManager.groupTemporaryAttributesUpdate(in: wholeRange) {
|
||||
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: wholeRange)
|
||||
for highlight in highlights {
|
||||
layoutManager.addTemporaryAttribute(.backgroundColor, value: highlight.item, forCharacterRange: highlight.range)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if highlights.isEmpty {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
progress.isFinished = true
|
||||
|
||||
if showsList {
|
||||
self.delegate?.textFinder(self, didFinishFindingAll: textFind.findString, results: results, textView: textView)
|
||||
}
|
||||
|
||||
if !results.isEmpty, let panel = self.findPanelController.window, panel.isVisible {
|
||||
panel.makeKey()
|
||||
}
|
||||
|
||||
UserDefaults.standard.appendHistory(self.findString, forKey: .findHistory)
|
||||
}
|
||||
|
||||
|
||||
/// Replace all matched strings and apply the result to views.
|
||||
@MainActor private func replaceAll() async {
|
||||
|
||||
guard let (textView, textFind) = self.prepareTextFind(forEditing: true) else { return }
|
||||
|
||||
textView.isEditable = false
|
||||
|
||||
let replacementString = self.replacementString
|
||||
|
||||
// setup progress sheet
|
||||
let progress = FindProgress(scope: textFind.scopeRange)
|
||||
let indicatorView = FindProgressView("Replace All", progress: progress, unit: .replacement)
|
||||
let indicator = NSHostingController(rootView: indicatorView)
|
||||
indicator.rootView.parent = indicator
|
||||
textView.viewControllerForSheet?.presentAsSheet(indicator)
|
||||
|
||||
let (replacementItems, selectedRanges) = await Task.detached(priority: .userInitiated) {
|
||||
textFind.replaceAll(with: replacementString) { (range, stop) in
|
||||
guard !progress.isCancelled else {
|
||||
stop = true
|
||||
return
|
||||
}
|
||||
|
||||
progress.completedUnit = range.upperBound
|
||||
progress.count += 1
|
||||
}
|
||||
}.value
|
||||
|
||||
textView.isEditable = true
|
||||
|
||||
guard !progress.isCancelled else { return }
|
||||
|
||||
if !replacementItems.isEmpty {
|
||||
// apply found strings to the text view
|
||||
textView.replace(with: replacementItems.map(\.string), ranges: replacementItems.map(\.range), selectedRanges: selectedRanges,
|
||||
actionName: "Replace All".localized)
|
||||
}
|
||||
|
||||
if progress.count > 0 {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
progress.isFinished = true
|
||||
|
||||
if let panel = self.findPanelController.window, panel.isVisible {
|
||||
panel.makeKey()
|
||||
}
|
||||
|
||||
self.delegate?.textFinder(self, didReplace: progress.count, textView: textView)
|
||||
|
||||
UserDefaults.standard.appendHistory(self.findString, forKey: .findHistory)
|
||||
UserDefaults.standard.appendHistory(self.replacementString, forKey: .replaceHistory)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: -
|
||||
|
||||
extension NSTextView {
|
||||
|
||||
@MainActor func unhighlight() {
|
||||
|
||||
self.layoutManager?.removeTemporaryAttribute(.backgroundColor, forCharacterRange: self.string.nsRange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -221,7 +221,7 @@ final class TextFindTests: XCTestCase {
|
||||
textFind = try TextFind(for: "abcdefg ABCDEFG", findString: "(?!=a)b(c)(?=d)",
|
||||
mode: .regularExpression(options: .caseInsensitive, unescapesReplacement: false))
|
||||
|
||||
(replacementItems, selectedRanges) = textFind.replaceAll(with: "$1\\\\t") { (_, _, _) in }
|
||||
(replacementItems, selectedRanges) = textFind.replaceAll(with: "$1\\\\t") { (_, _) in }
|
||||
XCTAssertEqual(replacementItems.count, 1)
|
||||
XCTAssertEqual(replacementItems[0].string, "ac\\tdefg AC\\tDEFG")
|
||||
XCTAssertEqual(replacementItems[0].range, NSRange(location: 0, length: 15))
|
||||
@ -234,7 +234,7 @@ final class TextFindTests: XCTestCase {
|
||||
selectedRanges: [NSRange(location: 1, length: 14),
|
||||
NSRange(location: 16, length: 7)])
|
||||
|
||||
(replacementItems, selectedRanges) = textFind.replaceAll(with: "_") { (_, _, _) in }
|
||||
(replacementItems, selectedRanges) = textFind.replaceAll(with: "_") { (_, _) in }
|
||||
XCTAssertEqual(replacementItems.count, 2)
|
||||
XCTAssertEqual(replacementItems[0].string, "bcdefg _defg")
|
||||
XCTAssertEqual(replacementItems[0].range, NSRange(location: 1, length: 14))
|
||||
|
Loading…
Reference in New Issue
Block a user