Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 23 additions & 24 deletions macOS/SynapseNotes/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2655,21 +2655,33 @@ class LinkAwareTextView: NSTextView {
}
}

private func applySearchHighlights(query: String, focusIndex: Int) {
guard let storage = textStorage, !query.isEmpty else {
clearSearchHighlights()
return
}
let content = storage.string
let needle = query.lowercased()
/// Non-overlapping UTF-16 `NSRange`s of `query` in `content` using a single case-folding
/// strategy (`.caseInsensitive`). Shared by find highlights, Replace All, and next-match so
/// the UI never disagrees with what replace operations will touch.
internal static func caseInsensitiveNonOverlappingMatchRanges(
in content: String,
query: String,
maxCount: Int? = nil
) -> [NSRange] {
guard !query.isEmpty else { return [] }
var matches: [NSRange] = []
var searchStart = content.startIndex
while searchStart < content.endIndex,
let range = content.range(of: needle, options: .caseInsensitive, range: searchStart..<content.endIndex) {
let range = content.range(of: query, options: .caseInsensitive, range: searchStart..<content.endIndex) {
matches.append(NSRange(range, in: content))
searchStart = range.upperBound
if matches.count > 2000 { break }
if let maxCount, matches.count >= maxCount { break }
}
return matches
}

private func applySearchHighlights(query: String, focusIndex: Int) {
guard let storage = textStorage, !query.isEmpty else {
clearSearchHighlights()
return
}
let content = storage.string
let matches = Self.caseInsensitiveNonOverlappingMatchRanges(in: content, query: query, maxCount: 2000)

let dimHighlight = NSColor.yellow.withAlphaComponent(0.30)
let focusHighlight = NSColor.yellow
Expand Down Expand Up @@ -2740,13 +2752,7 @@ class LinkAwareTextView: NSTextView {
private func replaceAllMatches(query: String, replacement: String) {
guard !query.isEmpty, let storage = textStorage else { return }
let content = storage.string
var matches: [NSRange] = []
var searchStart = content.startIndex
while searchStart < content.endIndex,
let r = content.range(of: query, options: .caseInsensitive, range: searchStart..<content.endIndex) {
matches.append(NSRange(r, in: content))
searchStart = r.upperBound
}
let matches = Self.caseInsensitiveNonOverlappingMatchRanges(in: content, query: query, maxCount: nil)
guard !matches.isEmpty else { return }

// Build the post-replace string in one shot, then ask the delegate to permit a
Expand All @@ -2773,14 +2779,7 @@ class LinkAwareTextView: NSTextView {
private func nextMatchIndex(forQuery query: String, after location: Int) -> Int {
guard let storage = textStorage else { return 0 }
let content = storage.string
var matches: [NSRange] = []
var searchStart = content.startIndex
while searchStart < content.endIndex,
let r = content.range(of: query, options: .caseInsensitive, range: searchStart..<content.endIndex) {
matches.append(NSRange(r, in: content))
searchStart = r.upperBound
if matches.count > 2000 { break }
}
let matches = Self.caseInsensitiveNonOverlappingMatchRanges(in: content, query: query, maxCount: 2000)
if matches.isEmpty { return 0 }
if let idx = matches.firstIndex(where: { $0.location >= location }) {
return idx
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import XCTest
@testable import Synapse

/// `LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges` must stay aligned with find
/// highlights and replace-all so ranges never disagree for the same query and body text.
final class LinkAwareTextViewFindMatchRangesTests: XCTestCase {

func test_emptyQuery_returnsNoRanges() {
let ranges = LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges(
in: "hello world",
query: "",
maxCount: nil
)
XCTAssertTrue(ranges.isEmpty)
}

func test_caseInsensitive_findsAllNonOverlapping() {
let ranges = LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges(
in: "Aa aa AA",
query: "aA",
maxCount: nil
)
XCTAssertEqual(ranges.count, 3)
}

func test_maxCount_stopsAfterFirst() {
let ranges = LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges(
in: "foo Foo FOO",
query: "foo",
maxCount: 1
)
XCTAssertEqual(ranges.count, 1)
}
}
Loading