From b52941d6981ac4bbfe3d16c1d631e3af08104fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sch=C3=B6nig?= Date: Tue, 23 Jan 2024 08:59:50 +1100 Subject: [PATCH] Select title/subtitle highlight as adviced by MKLocalSearch (#327) * Select title/subtitle highlight as adviced by MKLocalSearch * Highlights for various autocompletion providers * Add public init * Test compile fix * Example compile fixes * Ranges in calendar manager, too --- ...MemoryFavoriteManager+Autocompleting.swift | 4 +- ...nMemoryHistoryManager+Autocompleting.swift | 4 +- .../TKCalendarManager+Autocompleting.swift | 4 +- .../TKContactsManager+TKAutocompleting.swift | 19 ++--- Sources/TripKit/search/TKAppleGeocoder.swift | 6 +- .../search/TKAutocompletionResult+Score.swift | 52 ++++++++++--- .../search/TKAutocompletionResult.swift | 8 +- .../search/TKGeocodingResultScorer.swift | 28 ++++--- Sources/TripKit/search/TKPeliasGeocoder.swift | 22 ++++-- .../search/TKRegionAutocompleter.swift | 15 ++-- .../TripKit/search/TKRouteAutocompleter.swift | 2 +- Sources/TripKit/search/TKTripGoGeocoder.swift | 24 ++++-- .../views/TKUIAutocompletionResultCell.swift | 43 ++++++++--- .../TKAutocompletionResultTest.swift | 76 +++++++++---------- 14 files changed, 201 insertions(+), 106 deletions(-) diff --git a/Examples/TripKitUIExample/Autocompleter/InMemoryFavoriteManager+Autocompleting.swift b/Examples/TripKitUIExample/Autocompleter/InMemoryFavoriteManager+Autocompleting.swift index 226fda1ee..dc50c9f9a 100644 --- a/Examples/TripKitUIExample/Autocompleter/InMemoryFavoriteManager+Autocompleting.swift +++ b/Examples/TripKitUIExample/Autocompleter/InMemoryFavoriteManager+Autocompleting.swift @@ -61,8 +61,8 @@ extension InMemoryFavoriteManager { result.score = 90 } else { - let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title) - let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "") + let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title).score + let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "").score let rawScore = min(100, (titleScore + locationScore)/2) result.score = Int(TKAutocompletionResult.rangedScore(for: rawScore, min: 50, max: 90)) } diff --git a/Examples/TripKitUIExample/Autocompleter/InMemoryHistoryManager+Autocompleting.swift b/Examples/TripKitUIExample/Autocompleter/InMemoryHistoryManager+Autocompleting.swift index 543ac3b3b..e17e5492f 100644 --- a/Examples/TripKitUIExample/Autocompleter/InMemoryHistoryManager+Autocompleting.swift +++ b/Examples/TripKitUIExample/Autocompleter/InMemoryHistoryManager+Autocompleting.swift @@ -67,8 +67,8 @@ extension InMemoryHistoryManager { result.score = 90 } else { - let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title) - let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "") + let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title).score + let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "").score let rawScore = min(100, (titleScore + locationScore)/2) result.score = Int(TKAutocompletionResult.rangedScore(for: rawScore, min: 50, max: 90)) } diff --git a/Sources/TripKit/managers/TKCalendarManager+Autocompleting.swift b/Sources/TripKit/managers/TKCalendarManager+Autocompleting.swift index a0223685a..0c2c162b4 100644 --- a/Sources/TripKit/managers/TKCalendarManager+Autocompleting.swift +++ b/Sources/TripKit/managers/TKCalendarManager+Autocompleting.swift @@ -105,8 +105,10 @@ extension TKCalendarManager { } else { let titleScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: result.title) + result.titleHighlightRanges = titleScore.ranges let locationScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: result.subtitle ?? "") - let rawScore = min(100, (titleScore + locationScore) / 2) + result.subtitleHighlightRanges = locationScore.ranges + let rawScore = min(100, (titleScore.score + locationScore.score) / 2) result.score = Int(TKAutocompletionResult.rangedScore(for:rawScore, min: 50, max: 90)) } diff --git a/Sources/TripKit/managers/TKContactsManager+TKAutocompleting.swift b/Sources/TripKit/managers/TKContactsManager+TKAutocompleting.swift index 22554e1c1..42954aaff 100644 --- a/Sources/TripKit/managers/TKContactsManager+TKAutocompleting.swift +++ b/Sources/TripKit/managers/TKContactsManager+TKAutocompleting.swift @@ -81,19 +81,20 @@ extension TKContactsManager: TKAutocompleting { extension TKContactsManager.ContactAddress { fileprivate func toResult(provider: TKAutocompleting, search: String) -> TKAutocompletionResult { - var result = TKAutocompletionResult( + let nameScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: locationName) + let addressScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: address) + let textScore = min(100, (nameScore.score + addressScore.score) / 2) + let score = Int(TKAutocompletionResult.rangedScore(for: textScore, min: 50, max: 90)) + + return .init( object: self, title: locationName, + titleHighlightRanges: nameScore.ranges, subtitle: address, - image: image ?? TKAutocompletionResult.image(for: .contact) + subtitleHighlightRanges: addressScore.ranges, + image: image ?? TKAutocompletionResult.image(for: .contact), + score: score ) - - let nameScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: name) - let addressScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: address) - let textScore = min(100, (nameScore + addressScore) / 2) - result.score = Int(TKAutocompletionResult.rangedScore(for: textScore, min: 50, max: 90)) - - return result } } diff --git a/Sources/TripKit/search/TKAppleGeocoder.swift b/Sources/TripKit/search/TKAppleGeocoder.swift index ed39bd906..071eb0602 100644 --- a/Sources/TripKit/search/TKAppleGeocoder.swift +++ b/Sources/TripKit/search/TKAppleGeocoder.swift @@ -152,9 +152,11 @@ extension TKAutocompletionResult { self.init( object: completion, title: completion.title, + titleHighlightRanges: completion.titleHighlightRanges.map(\.rangeValue), subtitle: completion.subtitle, + subtitleHighlightRanges: completion.subtitleHighlightRanges.map(\.rangeValue), image: TKAutocompletionResult.image(for: .pin), - score: TKGeocodingResultScorer.calculateScore(title: completion.title, subtitle: completion.subtitle, searchTerm: input, minimum: 25, maximum: 65) - index + score: TKGeocodingResultScorer.calculateScore(title: completion.title, subtitle: completion.subtitle, searchTerm: input, minimum: 25, maximum: 65).score - index ) } @@ -168,7 +170,7 @@ extension TKNamedCoordinate { url = mapItem.url if let input = input, let region = region { - sortScore = Int(TKGeocodingResultScorer.calculateScore(for: self, searchTerm: input, near: region, allowLongDistance: false, minimum: 15, maximum: 65)) + sortScore = Int(TKGeocodingResultScorer.calculateScore(for: self, searchTerm: input, near: region, allowLongDistance: false, minimum: 15, maximum: 65).score) } } diff --git a/Sources/TripKit/search/TKAutocompletionResult+Score.swift b/Sources/TripKit/search/TKAutocompletionResult+Score.swift index 753305778..455c103a2 100644 --- a/Sources/TripKit/search/TKAutocompletionResult+Score.swift +++ b/Sources/TripKit/search/TKAutocompletionResult+Score.swift @@ -16,7 +16,7 @@ extension TKAutocompletionResult { if abs(world.span.latitudeDelta - region.span.latitudeDelta) < 1, abs(world.span.longitudeDelta - region.span.longitudeDelta) < 1 { return 100 } - + if region.contains(coordinate) { return 100 } @@ -35,6 +35,32 @@ extension TKAutocompletionResult { return Int(proportion * 100) } + public struct Score: ExpressibleByIntegerLiteral { + public let score: Int + public var ranges: [NSRange] = [] + + public init(score: Int, ranges: [NSRange] = []) { + self.score = score + self.ranges = ranges + } + + public init(integerLiteral value: IntegerLiteralType) { + self.score = value + } + } + + public struct ScoreHighlights { + public init(score: Int, titleHighlight: [NSRange] = [], subtitleHighlight: [NSRange] = []) { + self.score = score + self.titleHighlight = titleHighlight + self.subtitleHighlight = subtitleHighlight + } + + public let score: Int + public var titleHighlight: [NSRange] = [] + public var subtitleHighlight: [NSRange] = [] + } + /// 0: not match, e.g., we're missing a word /// 25: same words but wrong order /// 33: has all target words but missing a completed one @@ -42,7 +68,7 @@ extension TKAutocompletionResult { /// 66: contains all words in right order /// 75: matches start of word in search term (but starts don't match) /// 100: exact match at start - public static func nameScore(searchTerm fullTarget: String, candidate fullCandidate: String) -> Int { + public static func nameScore(searchTerm fullTarget: String, candidate fullCandidate: String) -> Score { let target = stringForScoring(fullTarget) let candidate = stringForScoring(fullCandidate) @@ -54,7 +80,7 @@ extension TKAutocompletionResult { } if target == candidate { - return 100 + return .init(score: 100, ranges: [.init(location: 0, length: candidate.utf8.count)]) } if target.isAbbreviation(for: candidate) || target.isAbbreviation(for: stringForScoring(fullCandidate, removeBrackets: true)) { @@ -65,36 +91,44 @@ extension TKAutocompletionResult { return 90 } + // exact phrase matches let excess = candidate.utf8.count - target.utf8.count if let range = candidate.range(of: target) { + let nsRange = NSRange(location: candidate.distance(from: candidate.startIndex, to: range.lowerBound), length: target.utf8.count) if range.lowerBound == candidate.startIndex { // matches right at start - return score(100, penalty: excess, min: 75) + return .init(score: score(100, penalty: excess, min: 75), ranges: [nsRange]) } let before = candidate[candidate.index(before: range.lowerBound)] if before.isWhitespace { // matches beginning of word let offset = candidate.distance(from: candidate.startIndex, to: range.lowerBound) - return score(75, penalty: offset * 2 + excess, min: 33) + return .init(score: score(75, penalty: offset * 2 + excess, min: 33), ranges: [nsRange]) } else { // in-word match - return score(25, penalty: excess, min: 5) + return .init(score: score(25, penalty: excess, min: 5), ranges: [nsRange]) } } // non-subscring matches let targetWords = target.components(separatedBy: " ") var lastMatch: String.Index = candidate.startIndex + var ranges: [NSRange] = [] for word in targetWords { if let match = candidate.range(of: word) { + ranges.append(NSRange( + location: candidate.distance(from: candidate.startIndex, to: match.lowerBound), + length: word.utf8.count + )) + if match.lowerBound >= lastMatch { // still in order, keep going lastMatch = match.lowerBound } else { // wrong order, abort with penalty - return score(10, penalty: excess, min: 0) + return .init(score: score(10, penalty: excess, min: 0), ranges: ranges) } } else { @@ -119,12 +153,12 @@ extension TKAutocompletionResult { // full word match, continue with next } else { // candidate doesn't have a completed word - return score(33, penalty: excess, min: 10) + return .init(score: score(33, penalty: excess, min: 10), ranges: ranges) } } } - return score(66, penalty: excess, min: 40) + return .init(score: score(66, penalty: excess, min: 40), ranges: ranges) } private static func score(_ maximum: Int, penalty: Int, min: Int) -> Int { diff --git a/Sources/TripKit/search/TKAutocompletionResult.swift b/Sources/TripKit/search/TKAutocompletionResult.swift index b2c967fe1..a64edfa92 100644 --- a/Sources/TripKit/search/TKAutocompletionResult.swift +++ b/Sources/TripKit/search/TKAutocompletionResult.swift @@ -9,10 +9,12 @@ import Foundation public struct TKAutocompletionResult { - public init(object: AnyHashable, title: String, subtitle: String? = nil, image: TKImage, accessoryButtonImage: TKImage? = nil, accessoryAccessibilityLabel: String? = nil, score: Int = 0, isInSupportedRegion: Bool = true) { + public init(object: AnyHashable, title: String, titleHighlightRanges: [NSRange] = [], subtitle: String? = nil, subtitleHighlightRanges: [NSRange] = [], image: TKImage, accessoryButtonImage: TKImage? = nil, accessoryAccessibilityLabel: String? = nil, score: Int = 0, isInSupportedRegion: Bool = true) { self.object = object self.title = title + self.titleHighlightRanges = titleHighlightRanges self.subtitle = subtitle + self.subtitleHighlightRanges = subtitleHighlightRanges self.image = image self.accessoryButtonImage = accessoryButtonImage self.accessoryAccessibilityLabel = accessoryAccessibilityLabel @@ -26,8 +28,12 @@ public struct TKAutocompletionResult { public let title: String + public var titleHighlightRanges: [NSRange] = [] + public var subtitle: String? = nil + public var subtitleHighlightRanges: [NSRange] = [] + public let image: TKImage public var accessoryButtonImage: TKImage? = nil diff --git a/Sources/TripKit/search/TKGeocodingResultScorer.swift b/Sources/TripKit/search/TKGeocodingResultScorer.swift index a7b32aee5..e364c52ba 100644 --- a/Sources/TripKit/search/TKGeocodingResultScorer.swift +++ b/Sources/TripKit/search/TKGeocodingResultScorer.swift @@ -14,40 +14,50 @@ public class TKGeocodingResultScorer: NSObject { private override init() { } - public static func calculateScore(for annotation: MKAnnotation, searchTerm: String, near region: MKCoordinateRegion, allowLongDistance: Bool, minimum: Int, maximum: Int) -> Int { + public static func calculateScore(for annotation: MKAnnotation, searchTerm: String, near region: MKCoordinateRegion, allowLongDistance: Bool, minimum: Int, maximum: Int) -> TKAutocompletionResult.ScoreHighlights { guard let title = (annotation.title ?? nil) else { - return 0 + return .init(score: 0) } let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: title) - let addressScore: Int + let addressScore: TKAutocompletionResult.Score if let subtitle = (annotation.subtitle ?? nil) { addressScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: subtitle) } else { addressScore = 0 } - let stringScore = max(titleScore, addressScore) + let stringScore = max(titleScore.score, addressScore.score) let distanceScore = TKAutocompletionResult.distanceScore(from: annotation.coordinate, to: region, longDistance: allowLongDistance) let rawScore = (stringScore + distanceScore) / 2 - return TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum) + let ranged = TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum) + return .init( + score: ranged, + titleHighlight: titleScore.ranges, + subtitleHighlight: addressScore.ranges + ) } - public static func calculateScore(title: String, subtitle: String?, searchTerm: String, minimum: Int, maximum: Int) -> Int { + public static func calculateScore(title: String, subtitle: String?, searchTerm: String, minimum: Int, maximum: Int) -> TKAutocompletionResult.ScoreHighlights { assert(maximum > minimum, "Order must be preserved.") let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: title) - let addressScore: Int + let addressScore: TKAutocompletionResult.Score if let subtitle = subtitle { addressScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: subtitle) } else { addressScore = 0 } - let rawScore = max(titleScore, addressScore) + let rawScore = max(titleScore.score, addressScore.score) - return TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum) + let ranged = TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum) + return .init( + score: ranged, + titleHighlight: titleScore.ranges, + subtitleHighlight: addressScore.ranges + ) } } diff --git a/Sources/TripKit/search/TKPeliasGeocoder.swift b/Sources/TripKit/search/TKPeliasGeocoder.swift index 78e1d95f2..49ac23383 100644 --- a/Sources/TripKit/search/TKPeliasGeocoder.swift +++ b/Sources/TripKit/search/TKPeliasGeocoder.swift @@ -88,7 +88,9 @@ extension TKPeliasGeocoder: TKGeocoding { hitSearch(components) { result in completion(result.map { coordinates in - coordinates.forEach { $0.setScore(searchTerm: input, near: region) } + coordinates.forEach { + $0.sortScore = $0.score(searchTerm: input, near: region).score + } return TKGeocoderHelper.mergedAndPruned(coordinates, withMaximum: 10) }) } @@ -115,14 +117,19 @@ extension TKPeliasGeocoder: TKAutocompleting { hitSearch(components) { result in completion(result.map { coordinates in - coordinates.forEach { $0.setScore(searchTerm: input, near: region) } - // Pelias likes coming back with similar locations near each // other, so we cluster them. let clusters = TKAnnotationClusterer.cluster(coordinates) let unique = clusters.compactMap(TKNamedCoordinate.namedCoordinate(for:)) let pruned = TKGeocoderHelper.mergedAndPruned(unique, withMaximum: 7) - return pruned.map(TKAutocompletionResult.init) + return pruned.map { + let score = $0.score(searchTerm: input, near: region) + var result = TKAutocompletionResult(from: $0) + result.score = score.score + result.titleHighlightRanges = score.titleHighlight + result.subtitleHighlightRanges = score.subtitleHighlight + return result + } }) } } @@ -134,10 +141,10 @@ extension TKPeliasGeocoder: TKAutocompleting { } -extension TKNamedCoordinate { +extension MKAnnotation { - func setScore(searchTerm: String, near region: MKCoordinateRegion) { - self.sortScore = Int(TKGeocodingResultScorer.calculateScore(for: self, searchTerm: searchTerm, near: region, allowLongDistance: false, minimum: 10, maximum: 60)) + func score(searchTerm: String, near region: MKCoordinateRegion) -> TKAutocompletionResult.ScoreHighlights { + TKGeocodingResultScorer.calculateScore(for: self, searchTerm: searchTerm, near: region, allowLongDistance: false, minimum: 10, maximum: 60) } } @@ -150,7 +157,6 @@ extension TKAutocompletionResult { title: coordinate.title ?? Loc.Location, subtitle: coordinate.subtitle, image: TKAutocompletionResult.image(for: .pin), - score: coordinate.sortScore, isInSupportedRegion: TKRegionManager.shared.coordinateIsPartOfAnyRegion(coordinate.coordinate) ) } diff --git a/Sources/TripKit/search/TKRegionAutocompleter.swift b/Sources/TripKit/search/TKRegionAutocompleter.swift index 4ed1bbd40..9d3cabffc 100644 --- a/Sources/TripKit/search/TKRegionAutocompleter.swift +++ b/Sources/TripKit/search/TKRegionAutocompleter.swift @@ -22,18 +22,18 @@ public class TKRegionAutocompleter: TKAutocompleting { public func autocomplete(_ input: String, near mapRect: MKMapRect, completion: @escaping (Result<[TKAutocompletionResult], Error>) -> Void) { let scoredMatches = TKRegionManager.shared.regions - .flatMap { region -> [(TKRegion.City, score: Int)] in + .flatMap { region -> [(TKRegion.City, score: TKAutocompletionResult.ScoreHighlights)] in if input.isEmpty { - return region.cities.map { ($0, 100) } + return region.cities.map { ($0, .init(score: 100)) } } else { - return region.cities.compactMap { city in + return region.cities.compactMap { city -> (TKRegion.City, score: TKAutocompletionResult.ScoreHighlights)? in guard let name = city.title else { return nil } let titleScore = TKAutocompletionResult.nameScore(searchTerm: input, candidate: name) - guard titleScore > 0 else { return nil } + guard titleScore.score > 0 else { return nil } let distanceScore = TKAutocompletionResult.distanceScore(from: city.coordinate, to: .init(mapRect), longDistance: true) - let rawScore = (titleScore * 9 + distanceScore) / 10 + let rawScore = (titleScore.score * 9 + distanceScore) / 10 let score = TKAutocompletionResult.rangedScore(for: rawScore, min: 10, max: 70) - return (city, score) + return (city, .init(score: score, titleHighlight: titleScore.ranges)) } } } @@ -43,8 +43,9 @@ public class TKRegionAutocompleter: TKAutocompleting { return TKAutocompletionResult( object: tuple.0, title: tuple.0.title!, // we filtered those out without a name + titleHighlightRanges: tuple.score.titleHighlight, image: image, - score: tuple.score + score: tuple.score.score ) } diff --git a/Sources/TripKit/search/TKRouteAutocompleter.swift b/Sources/TripKit/search/TKRouteAutocompleter.swift index 9de79a762..2e42f0829 100644 --- a/Sources/TripKit/search/TKRouteAutocompleter.swift +++ b/Sources/TripKit/search/TKRouteAutocompleter.swift @@ -56,7 +56,7 @@ public class TKRouteAutocompleter: TKAutocompleting { let rawScore = [route.shortName, route.routeName] .compactMap { $0 } .map { - TKAutocompletionResult.nameScore(searchTerm: input, candidate: $0) + TKAutocompletionResult.nameScore(searchTerm: input, candidate: $0).score }.max() ?? 0 let score = TKAutocompletionResult.rangedScore(for: rawScore, min: 30, max: 80) return (route, score) diff --git a/Sources/TripKit/search/TKTripGoGeocoder.swift b/Sources/TripKit/search/TKTripGoGeocoder.swift index e2f88251f..fe2c42c0c 100644 --- a/Sources/TripKit/search/TKTripGoGeocoder.swift +++ b/Sources/TripKit/search/TKTripGoGeocoder.swift @@ -103,23 +103,28 @@ extension TKTripGoGeocoder: TKAutocompleting { let coordinates = response.choices.map(\.named) let results = coordinates.compactMap { named -> TKAutocompletionResult? in guard let name = named.name else { return nil } + let tuple = Self.score(named, query: input) if let stop = named as? TKStopCoordinate { return TKAutocompletionResult( object: named, title: name, + titleHighlightRanges: tuple.titleHighlight, subtitle: stop.stopCode.contains(input) ? (stop.stopCode + " - " + (stop.services ?? "")) : stop.services, + subtitleHighlightRanges: tuple.subtitleHighlight, image: TKModeImageFactory.shared.image(for: stop.stopModeInfo) ?? TKAutocompletionResult.image(for: .pin), accessoryButtonImage: TKStyleManager.image(named: "icon-search-timetable"), accessoryAccessibilityLabel: Loc.ShowTimetable, - score: Self.score(named, query: input) + score: tuple.score ) } else { return TKAutocompletionResult( object: named, title: name, + titleHighlightRanges: tuple.titleHighlight, subtitle: named.address, + subtitleHighlightRanges: tuple.subtitleHighlight, image: TKAutocompletionResult.image(for: .pin), - score: Self.score(named, query: input) + score: tuple.score ) } } @@ -151,10 +156,10 @@ extension TKTripGoGeocoder: TKAutocompleting { extension TKTripGoGeocoder { private static func assignScore(to named: TKNamedCoordinate, query: String? = nil) { - named.sortScore = score(named, query: query) + named.sortScore = score(named, query: query).score } - private static func score(_ named: TKNamedCoordinate, query: String? = nil) -> Int { + private static func score(_ named: TKNamedCoordinate, query: String? = nil) -> TKAutocompletionResult.ScoreHighlights { if let stop = named as? TKStopCoordinate { let popularity = stop.stopSortScore ?? 0 let maxScore = 1_000 @@ -164,16 +169,19 @@ extension TKTripGoGeocoder { let moreThanMax = popularity / maxScore ranged += TKAutocompletionResult.rangedScore(for: moreThanMax, min: 0, max: 10) } - return ranged + let highlight = query.map { + TKAutocompletionResult.nameScore(searchTerm: $0, candidate: stop.title ?? "").ranges + } + return .init(score: ranged, titleHighlight: highlight ?? []) } else if let query = query, let name = named.name ?? named.title { let titleScore = TKAutocompletionResult.nameScore(searchTerm: query, candidate: name) - let ranged = TKAutocompletionResult.rangedScore(for: titleScore, min: 0, max: 50) - return ranged + let ranged = TKAutocompletionResult.rangedScore(for: titleScore.score, min: 0, max: 50) + return .init(score: ranged, titleHighlight: titleScore.ranges) } else { assertionFailure("Unexpected geocoder result: \(named)") - return 0 + return .init(score: 0) } } } diff --git a/Sources/TripKitUI/views/TKUIAutocompletionResultCell.swift b/Sources/TripKitUI/views/TKUIAutocompletionResultCell.swift index fd69c18c0..70d1c6f02 100644 --- a/Sources/TripKitUI/views/TKUIAutocompletionResultCell.swift +++ b/Sources/TripKitUI/views/TKUIAutocompletionResultCell.swift @@ -28,24 +28,43 @@ class TKUIAutocompletionResultCell: UITableViewCell { } +extension UILabel { + fileprivate func set(text: String?, highlightRanges: [NSRange], textColor: UIColor) { + guard let text else { + self.attributedText = nil + return + } + + var attributed = NSMutableAttributedString(string: text, attributes: [ + .foregroundColor: textColor, + .font: TKStyleManager.customFont(forTextStyle: .body), + ]) + for range in highlightRanges { + attributed.addAttribute(.font, value: TKStyleManager.boldCustomFont(forTextStyle: .body), range: range) + } + self.attributedText = attributed + } +} + extension TKUIAutocompletionResultCell { - func configure(title: String, subtitle: String? = nil, image: UIImage? = nil) { + private func configure(title: String, titleHighlightRanges: [NSRange] = [], subtitle: String? = nil, subtitleHighlightRanges: [NSRange] = [], image: UIImage? = nil) { imageView?.image = image imageView?.tintColor = .tkLabelPrimary - textLabel?.text = title - textLabel?.textColor = .tkLabelPrimary - detailTextLabel?.text = subtitle - detailTextLabel?.textColor = .tkLabelSecondary + textLabel?.set(text: title, highlightRanges: titleHighlightRanges, textColor: .tkLabelPrimary) + detailTextLabel?.set(text: subtitle, highlightRanges: subtitleHighlightRanges, textColor: .tkLabelSecondary) contentView.alpha = 1 accessoryView = nil } func configure(with item: TKUIAutocompletionViewModel.Item, onAccessoryTapped: ((TKUIAutocompletionViewModel.Item) -> Void)? = nil) { switch item { - case .currentLocation: configureCurrentLocation(with: item) - case .action: configureAction(with: item) - case .autocompletion: configureAutocompletion(with: item, onAccessoryTapped: onAccessoryTapped) + case .currentLocation: + configureCurrentLocation(with: item) + case .action: + configureAction(with: item) + case .autocompletion: + configureAutocompletion(with: item, onAccessoryTapped: onAccessoryTapped) } } @@ -57,7 +76,13 @@ extension TKUIAutocompletionResultCell { private func configureAutocompletion(with item: TKUIAutocompletionViewModel.Item, onAccessoryTapped: ((TKUIAutocompletionViewModel.Item) -> Void)?) { guard case .autocompletion(let autocompletion) = item else { assertionFailure(); return } - configure(title: autocompletion.title, subtitle: autocompletion.subtitle, image: autocompletion.image) + configure( + title: autocompletion.title, + titleHighlightRanges: autocompletion.completion.titleHighlightRanges, + subtitle: autocompletion.subtitle, + subtitleHighlightRanges: autocompletion.completion.subtitleHighlightRanges, + image: autocompletion.image + ) contentView.alpha = autocompletion.showFaded ? 0.33 : 1 if #available(iOS 14.0, *), let accessoryImage = autocompletion.accessoryImage, let target = onAccessoryTapped { diff --git a/Tests/TripKitTests/searching/TKAutocompletionResultTest.swift b/Tests/TripKitTests/searching/TKAutocompletionResultTest.swift index 0f75f49e7..593f2c1e4 100644 --- a/Tests/TripKitTests/searching/TKAutocompletionResultTest.swift +++ b/Tests/TripKitTests/searching/TKAutocompletionResultTest.swift @@ -19,135 +19,135 @@ final class TKAutocompletionResultTest: XCTestCase { func testHomeAutocompletion() { let searchTerm = "Home" - var score: Int + var score: TKAutocompletionResult.Score score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:" Homebush") - XCTAssertEqual(score, 100 - 4) + XCTAssertEqual(score.score, 100 - 4) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"home") - XCTAssertEqual(score, 100) + XCTAssertEqual(score.score, 100) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Home Thai") - XCTAssertEqual(score, 100 - 5) + XCTAssertEqual(score.score, 100 - 5) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Indian Home Diner") - XCTAssertEqual(score, 48) // penalty for not matching start and excess + XCTAssertEqual(score.score, 48) // penalty for not matching start and excess score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"") - XCTAssertEqual(score, 100) + XCTAssertEqual(score.score, 100) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"My Home") - XCTAssertEqual(score, 75 - 3*2 - 3) // penalty for not matching start + XCTAssertEqual(score.score, 75 - 3*2 - 3) // penalty for not matching start score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Adrian's Home") - XCTAssertEqual(score, 75 - 8*2 - 8) // penalty for not matching start + XCTAssertEqual(score.score, 75 - 8*2 - 8) // penalty for not matching start score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"PCHome") - XCTAssertEqual(score, 25 - 2) // penalty for not matching start of word + XCTAssertEqual(score.score, 25 - 2) // penalty for not matching start of word score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"hxoxmxex") - XCTAssertEqual(score, 0) + XCTAssertEqual(score.score, 0) } func testMultipleSearchTerms() { let searchTerm = "Max B" - var score: Int + var score: TKAutocompletionResult.Score score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Max Brenner Chocolate Bar") - XCTAssertEqual(score, 100 - 20) + XCTAssertEqual(score.score, 100 - 20) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"max black") - XCTAssertEqual(score, 100 - 4) + XCTAssertEqual(score.score, 100 - 4) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"The MAX BLACK") - XCTAssertEqual(score, 75 - 4*2 - 8) // penalty for not starting with it + XCTAssertEqual(score.score, 75 - 4*2 - 8) // penalty for not starting with it score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Max Power Bullets") - XCTAssertEqual(score, 66 - 12) // Right order and all words complete + XCTAssertEqual(score.score, 66 - 12) // Right order and all words complete score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Maxwell's Cafe Bar Espresso") - XCTAssertEqual(score, 33 - 21) // Penalty for not having a completed word + XCTAssertEqual(score.score, 33 - 21) // Penalty for not having a completed word score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"B Max Property Group") - XCTAssertEqual(score, 0) // Penalty for order mismatch + XCTAssertEqual(score.score, 0) // Penalty for order mismatch score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"B&N - Max PTY LTD") - XCTAssertEqual(score, 1) // Penalty for order mismatch + XCTAssertEqual(score.score, 1) // Penalty for order mismatch } func testTrainStationSearchTerms() { let searchTerm = "Ashfield S" - var score: Int + var score: TKAutocompletionResult.Score score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Ashfield Station") - XCTAssertEqual(score, 100 - 6) + XCTAssertEqual(score.score, 100 - 6) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Ashfield (Station)") - XCTAssertEqual(score, 100 - 6) + XCTAssertEqual(score.score, 100 - 6) score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Brown St near Ashfield") - XCTAssertEqual(score, 0) // order mismatch + XCTAssertEqual(score.score, 0) // order mismatch score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Ses Fashions") - XCTAssertEqual(score, 0) // missing a word + XCTAssertEqual(score.score, 0) // missing a word } func testBadFuzzyMatches() { - var score: Int + var score: TKAutocompletionResult.Score score = TKAutocompletionResult.nameScore(searchTerm: "Brandon Ave", candidate:"Another Ave") - XCTAssertEqual(score, 0) + XCTAssertEqual(score.score, 0) // This is debatable. Would be nice if you still scored some points score = TKAutocompletionResult.nameScore(searchTerm: "Brandon Ave,Sydney", candidate:"Brandon Avenue") - XCTAssertEqual(score, 0) + XCTAssertEqual(score.score, 0) } func testDeeWhyAutocompletion5147() { let searchTerm = "Dee Why" - var score: Int + var score: TKAutocompletionResult.Score score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Big Red Dee Why") - XCTAssertEqual(score, 75 - 8*2 - 8) // penalty for not matching start + XCTAssertEqual(score.score, 75 - 8*2 - 8) // penalty for not matching start score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Dee Why NSW") - XCTAssertEqual(score, 100 - 4) + XCTAssertEqual(score.score, 100 - 4) } func testGeorgeStreet() { let searchTerm = "george street" - var score: Int + var score: TKAutocompletionResult.Score score = TKAutocompletionResult.nameScore(searchTerm:searchTerm, candidate:"Telstra Loading Dock 400 George Street Sydney") - XCTAssertEqual(score, 33) + XCTAssertEqual(score.score, 33) } func testAbbreviations() { - var score: Int + var score: TKAutocompletionResult.Score score = TKAutocompletionResult.nameScore(searchTerm: "moma", candidate:"Museum of Modern Art") - XCTAssertEqual(score, 95) // minor penalty for abbreviations + XCTAssertEqual(score.score, 95) // minor penalty for abbreviations score = TKAutocompletionResult.nameScore(searchTerm: "moma", candidate:"Museum of Modern Art (MoMA)") - XCTAssertEqual(score, 95) // minor penalty for abbreviations + XCTAssertEqual(score.score, 95) // minor penalty for abbreviations score = TKAutocompletionResult.nameScore(searchTerm: "museum of modern art", candidate:"MoMA") - XCTAssertEqual(score, 90) // minor penalty for abbreviations + XCTAssertEqual(score.score, 90) // minor penalty for abbreviations score = TKAutocompletionResult.nameScore(searchTerm: "mcg", candidate:"Melbourne Cricket Ground") - XCTAssertEqual(score, 95) // minor penalty for abbreviations + XCTAssertEqual(score.score, 95) // minor penalty for abbreviations score = TKAutocompletionResult.nameScore(searchTerm: "m", candidate:"Melbourne") - XCTAssertEqual(score, 92) // abbreviation is too short, but it's valid autocompletion + XCTAssertEqual(score.score, 92) // abbreviation is too short, but it's valid autocompletion score = TKAutocompletionResult.nameScore(searchTerm: "mc", candidate:"Melbourne Cricket") - XCTAssertEqual(score, 0) // abbreviation is too short + XCTAssertEqual(score.score, 0) // abbreviation is too short } }