mirror of
https://github.com/coteditor/CotEditor.git
synced 2024-09-11 11:25:57 +03:00
Count all element in multiple selections (close #978)
This commit is contained in:
parent
25d5c0e04a
commit
bd63200f2e
@ -7,6 +7,7 @@ Change Log
|
||||
|
||||
### Improvements
|
||||
|
||||
- Change counting characters/lines/words to count all elements in multiple selections.
|
||||
- Change the threshold to trigger the automatic completion to 3 letters or more to optimize calculation time by large documents.
|
||||
- Allow `_` as a character for the automatic completion candidates.
|
||||
|
||||
@ -16,6 +17,7 @@ Change Log
|
||||
- Fix an issue that the application did not terminate when no documents exist and the application goes background.
|
||||
|
||||
|
||||
|
||||
4.5.0-beta.2 (555)
|
||||
--------------------------
|
||||
|
||||
|
@ -90,9 +90,16 @@ import AppKit
|
||||
.merge(with: self.setting.objectWillChange)
|
||||
.merge(with: Just(Void())) // initial calculation
|
||||
.receive(on: DispatchQueue.main)
|
||||
.compactMap { [weak self] in self?.textView.selectedString }
|
||||
.compactMap { [weak self] in self?.textView.selectedStrings }
|
||||
.receive(on: DispatchQueue.global())
|
||||
.map { [unowned self] in $0.count(options: self.setting.options) }
|
||||
.map { [unowned self] (strings) in
|
||||
strings
|
||||
.map { $0.count(options: self.setting.options) }
|
||||
.reduce(0) { (total, count) in
|
||||
guard let total, let count else { return nil }
|
||||
return total + count
|
||||
}
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &self.$selectionCount)
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// © 2004-2007 nakamuxu
|
||||
// © 2014-2022 1024jp
|
||||
// © 2014-2023 1024jp
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
@ -26,6 +26,9 @@
|
||||
|
||||
import Cocoa
|
||||
|
||||
extension NSValue: @unchecked Sendable { }
|
||||
|
||||
|
||||
final class DocumentAnalyzer {
|
||||
|
||||
// MARK: Public Properties
|
||||
@ -91,12 +94,13 @@ final class DocumentAnalyzer {
|
||||
try await Task.sleep(nanoseconds: delay * 1_000_000) // debounce
|
||||
|
||||
let string = await textView.string.immutable
|
||||
let selectedRange = await textView.selectedRange
|
||||
let selectedRanges = await textView.selectedRanges
|
||||
.map(\.rangeValue)
|
||||
.compactMap { Range($0, in: string) }
|
||||
|
||||
let counter = EditorCounter(string: string,
|
||||
selectedRange: Range(selectedRange, in: string) ?? string.startIndex..<string.startIndex,
|
||||
requiredInfo: self.requiredInfoTypes,
|
||||
countsWholeText: self.needsCountWholeText)
|
||||
guard !selectedRanges.isEmpty else { return assertionFailure() }
|
||||
|
||||
let counter = EditorCounter(string: string, selectedRanges: selectedRanges, requiredInfo: self.requiredInfoTypes, countsWholeText: self.needsCountWholeText)
|
||||
|
||||
var result = try await counter.count()
|
||||
|
||||
|
@ -8,7 +8,7 @@
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// © 2014-2022 1024jp
|
||||
// © 2014-2023 1024jp
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
@ -82,7 +82,7 @@ final actor EditorCounter {
|
||||
// MARK: Private Properties
|
||||
|
||||
let string: String
|
||||
let selectedRange: Range<String.Index>
|
||||
let selectedRanges: [Range<String.Index>]
|
||||
|
||||
let requiredInfo: EditorInfoTypes
|
||||
let countsWholeText: Bool
|
||||
@ -92,12 +92,12 @@ final actor EditorCounter {
|
||||
// MARK: -
|
||||
// MARK: Lifecycle
|
||||
|
||||
init(string: String, selectedRange: Range<String.Index>, requiredInfo: EditorInfoTypes, countsWholeText: Bool) {
|
||||
init(string: String, selectedRanges: [Range<String.Index>], requiredInfo: EditorInfoTypes, countsWholeText: Bool) {
|
||||
|
||||
assert(selectedRange.upperBound <= string.endIndex)
|
||||
assert(selectedRanges.allSatisfy { $0.upperBound <= string.endIndex })
|
||||
|
||||
self.string = string
|
||||
self.selectedRange = selectedRange
|
||||
self.selectedRanges = selectedRanges
|
||||
self.requiredInfo = requiredInfo
|
||||
self.countsWholeText = countsWholeText
|
||||
}
|
||||
@ -110,7 +110,7 @@ final actor EditorCounter {
|
||||
|
||||
var result = EditorCountResult()
|
||||
|
||||
let selectedString = self.string[self.selectedRange]
|
||||
let selectedStrings = self.selectedRanges.map { self.string[$0] }
|
||||
|
||||
if self.countsWholeText {
|
||||
if self.requiredInfo.contains(.characters) {
|
||||
@ -131,38 +131,38 @@ final actor EditorCounter {
|
||||
|
||||
if self.requiredInfo.contains(.characters) {
|
||||
try Task.checkCancellation()
|
||||
result.characters.selected = selectedString.count
|
||||
result.characters.selected = selectedStrings.map(\.count).reduce(0, +)
|
||||
}
|
||||
|
||||
if self.requiredInfo.contains(.lines) {
|
||||
try Task.checkCancellation()
|
||||
result.lines.selected = selectedString.numberOfLines
|
||||
result.lines.selected = self.string.numberOfLines(in: self.selectedRanges)
|
||||
}
|
||||
|
||||
if self.requiredInfo.contains(.words) {
|
||||
try Task.checkCancellation()
|
||||
result.words.selected = selectedString.numberOfWords
|
||||
result.words.selected = selectedStrings.map(\.numberOfWords).reduce(0, +)
|
||||
}
|
||||
|
||||
if self.requiredInfo.contains(.location) {
|
||||
try Task.checkCancellation()
|
||||
result.location = self.string.distance(from: self.string.startIndex,
|
||||
to: self.selectedRange.lowerBound)
|
||||
to: self.selectedRanges[0].lowerBound)
|
||||
}
|
||||
|
||||
if self.requiredInfo.contains(.line) {
|
||||
try Task.checkCancellation()
|
||||
result.line = self.string.lineNumber(at: self.selectedRange.lowerBound)
|
||||
result.line = self.string.lineNumber(at: self.selectedRanges[0].lowerBound)
|
||||
}
|
||||
|
||||
if self.requiredInfo.contains(.column) {
|
||||
try Task.checkCancellation()
|
||||
result.column = self.string.columnNumber(at: self.selectedRange.lowerBound)
|
||||
result.column = self.string.columnNumber(at: self.selectedRanges[0].lowerBound)
|
||||
}
|
||||
|
||||
if self.requiredInfo.contains(.unicode) {
|
||||
result.unicode = (selectedString.compareCount(with: 1) == .equal)
|
||||
? selectedString.first?.unicodeScalars.map(\.codePoint).joined(separator: ", ")
|
||||
result.unicode = (selectedStrings[0].compareCount(with: 1) == .equal)
|
||||
? selectedStrings[0].first?.unicodeScalars.map(\.codePoint).joined(separator: ", ")
|
||||
: nil
|
||||
}
|
||||
|
||||
|
@ -34,6 +34,15 @@ extension NSTextView {
|
||||
}
|
||||
|
||||
|
||||
/// The selected strings.
|
||||
var selectedStrings: [String] {
|
||||
|
||||
self.selectedRanges
|
||||
.map(\.rangeValue)
|
||||
.map { (self.string as NSString).substring(with: $0) }
|
||||
}
|
||||
|
||||
|
||||
/// character just before the given range
|
||||
func character(before range: NSRange) -> Unicode.Scalar? {
|
||||
|
||||
|
@ -81,6 +81,38 @@ extension StringProtocol {
|
||||
}
|
||||
|
||||
|
||||
/// Count the number of lines in the given ranges including the last blank line.
|
||||
///
|
||||
/// - Parameter ranges: The character ranges to count lines.
|
||||
/// - Returns: The number of lines.
|
||||
func numberOfLines(in ranges: [Range<String.Index>]) -> Int {
|
||||
|
||||
assert(!ranges.isEmpty)
|
||||
|
||||
if self.isEmpty || ranges.isEmpty { return 0 }
|
||||
|
||||
// use simple count for efficiency
|
||||
if ranges.count == 1 {
|
||||
return self.numberOfLines(in: ranges[0])
|
||||
}
|
||||
|
||||
// evaluate line ranges to avoid double-count lines holding multiple ranges
|
||||
var lineRanges: [Range<String.Index>] = []
|
||||
for range in ranges {
|
||||
let lineRange = self.lineRange(for: range)
|
||||
self.enumerateSubstrings(in: lineRange, options: [.byLines, .substringNotRequired]) { (_, substringRange, _, _) in
|
||||
lineRanges.append(substringRange)
|
||||
}
|
||||
|
||||
if self[range].last?.isNewline == true {
|
||||
lineRanges.append(self.lineRange(at: range.upperBound))
|
||||
}
|
||||
}
|
||||
|
||||
return lineRanges.unique.count
|
||||
}
|
||||
|
||||
|
||||
/// Calculate the number of characters from the beginning of the line where the given character index locates (0-based).
|
||||
///
|
||||
/// - Parameter index: The character index.
|
||||
|
@ -8,7 +8,7 @@
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// © 2020-2022 1024jp
|
||||
// © 2020-2023 1024jp
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
@ -39,7 +39,7 @@ final class EditorInfoCountOperationTests: XCTestCase {
|
||||
let selectedRange = Range(NSRange(0..<3), in: self.testString)!
|
||||
let counter = EditorCounter(
|
||||
string: self.testString,
|
||||
selectedRange: selectedRange,
|
||||
selectedRanges: [selectedRange],
|
||||
requiredInfo: [],
|
||||
countsWholeText: true)
|
||||
|
||||
@ -59,7 +59,7 @@ final class EditorInfoCountOperationTests: XCTestCase {
|
||||
let selectedRange = Range(NSRange(11..<21), in: self.testString)!
|
||||
let counter = EditorCounter(
|
||||
string: self.testString,
|
||||
selectedRange: selectedRange,
|
||||
selectedRanges: [selectedRange],
|
||||
requiredInfo: .all,
|
||||
countsWholeText: true)
|
||||
|
||||
@ -84,7 +84,7 @@ final class EditorInfoCountOperationTests: XCTestCase {
|
||||
let selectedRange = Range(NSRange(11..<21), in: self.testString)!
|
||||
let counter = EditorCounter(
|
||||
string: self.testString,
|
||||
selectedRange: selectedRange,
|
||||
selectedRanges: [selectedRange],
|
||||
requiredInfo: .all,
|
||||
countsWholeText: false)
|
||||
|
||||
@ -110,7 +110,7 @@ final class EditorInfoCountOperationTests: XCTestCase {
|
||||
let selectedRange = Range(NSRange(1..<4), in: string)!
|
||||
let counter = EditorCounter(
|
||||
string: string,
|
||||
selectedRange: selectedRange,
|
||||
selectedRanges: [selectedRange],
|
||||
requiredInfo: .all,
|
||||
countsWholeText: true)
|
||||
|
||||
|
@ -136,6 +136,10 @@ final class StringExtensionsTests: XCTestCase {
|
||||
XCTAssertEqual("a\u{FEFF}\nb".numberOfLines, 2)
|
||||
XCTAssertEqual("\u{FEFF}\nb".numberOfLines, 2)
|
||||
XCTAssertEqual("\u{FEFF}0000000000000000".numberOfLines, 1)
|
||||
|
||||
let bomString = "\u{FEFF}\nb"
|
||||
let range = bomString.startIndex..<bomString.index(bomString.startIndex, offsetBy: 2)
|
||||
XCTAssertEqual(bomString.numberOfLines(in: [range, range]), 2) // "\u{FEFF}\nb"
|
||||
}
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user