Improve Replace All progress

This commit is contained in:
1024jp 2022-12-22 21:01:55 +09:00
parent ca2ab5e619
commit d11eecdce8
6 changed files with 218 additions and 223 deletions

View File

@ -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.

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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))