From 00c025e945bb163139a7a34d8fcc61083928911b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20G=C3=BCnd=C3=BCz?= Date: Tue, 13 Oct 2026 14:24:02 +0200 Subject: [PATCH] Highlight the right characters when folding changes the length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highlightMatchingTokenizedPrefixes searched a folded copy of the string and applied the found range to the original. Folding changes the length of characters like "é" or full-width letters, so with "Café Olé" and the search "ole" it highlighted " Ol" instead of "Olé". It now searches the original string with the folding as comparison options, passes the locale on to the tokenization, and drops the @available attribute that only repeated the package's minimum versions. --- .../HandySwiftUI/Extensions/StringExt.swift | 17 ++++++----- Tests/HandySwiftUITests/StringExtTests.swift | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 Tests/HandySwiftUITests/StringExtTests.swift diff --git a/Sources/HandySwiftUI/Extensions/StringExt.swift b/Sources/HandySwiftUI/Extensions/StringExt.swift index 009bdc8..cb056b8 100644 --- a/Sources/HandySwiftUI/Extensions/StringExt.swift +++ b/Sources/HandySwiftUI/Extensions/StringExt.swift @@ -50,7 +50,7 @@ extension String { /// - Parameters: /// - searchText: The text to search for within the string. /// - locale: The locale to use for string comparisons. Defaults to `nil`. - /// - applytingAttributes: A closure that gets passed an `AttributedSubstring` you can customize to define how to highlight. By default, a yellow background and bold black foreground text is used. + /// - applyingAttributes: A closure that gets passed an `AttributedSubstring` you can customize to define how to highlight. By default, a yellow background and bold black foreground text is used. /// /// - Returns: An `AttributedString` with matching prefixes highlighted. /// @@ -60,7 +60,6 @@ extension String { /// let highlighted = text.highlightMatchingTokenizedPrefixes(in: "He Wo") /// // Returns an AttributedString with "He" and "Wo" in bold /// ``` - @available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *) public func highlightMatchingTokenizedPrefixes( in searchText: String, locale: Locale? = nil, @@ -71,12 +70,16 @@ extension String { } ) -> AttributedString { var attributedSelf = AttributedString(self) - let normalizedSelf = self.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale) - for token in searchText.tokenized() { - if let range = AttributedString(normalizedSelf).range(of: token) { - applyingAttributes(&attributedSelf[range]) - } + for token in searchText.tokenized(locale: locale) where !token.isEmpty { + // Search the original string with the folding as comparison options instead of searching a folded copy: folding can change + // the length (e.g. "é" or full-width letters), so ranges found in a folded copy point to the wrong characters in the original + guard + let stringRange = self.range(of: token, options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale), + let range = Range(stringRange, in: attributedSelf) + else { continue } + + applyingAttributes(&attributedSelf[range]) } return attributedSelf diff --git a/Tests/HandySwiftUITests/StringExtTests.swift b/Tests/HandySwiftUITests/StringExtTests.swift new file mode 100644 index 0000000..557034a --- /dev/null +++ b/Tests/HandySwiftUITests/StringExtTests.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Testing + +@testable import HandySwiftUI + +struct StringExtTests { + func highlightedParts(of text: String, searchText: String) -> [String] { + let highlighted = text.highlightMatchingTokenizedPrefixes(in: searchText) + return highlighted.runs.filter { $0.backgroundColor != nil }.map { String(highlighted[$0.range].characters) } + } + + @Test + func highlightsPlainMatches() { + #expect(self.highlightedParts(of: "Hello, World!", searchText: "He Wo") == ["He", "Wo"]) + } + + @Test( + arguments: [ + ("Café Olé", "ole", ["Olé"]), + ("Hello World", "wor", ["Wor"]), + ("Ärger über alles", "alles", ["alles"]), + ("Ärger über alles", "uber", ["üb" + "er"]), + ] + ) + func highlightsTheMatchingCharactersWhenFoldingChangesTheLength(text: String, searchText: String, expected: [String]) { + #expect(self.highlightedParts(of: text, searchText: searchText) == expected) + } +}