From 4ca57f8c76a65a12c4473732ea4012b4e8b34d8f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Thu, 1 Dec 2022 18:26:48 -0500 Subject: [PATCH] Better case-insensitive sorting for lists --- Tusker.xcodeproj/project.pbxproj | 8 +++ Tusker/API/MastodonController.swift | 21 +------ Tusker/SemiCaseSensitiveComparator.swift | 61 +++++++++++++++++++ .../SemiCaseSensitiveComparatorTests.swift | 24 ++++++++ 4 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 Tusker/SemiCaseSensitiveComparator.swift create mode 100644 TuskerTests/SemiCaseSensitiveComparatorTests.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index d5a00af0..665d8fda 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; }; D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; }; D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759A29384F9C00C0B37F /* FilterMO.swift */; }; + D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; }; + D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; @@ -422,6 +424,8 @@ D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = ""; }; D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = ""; }; D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = ""; }; + D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = ""; }; + D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparatorTests.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; @@ -1424,6 +1428,7 @@ D6B81F432560390300F6E31D /* MenuController.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, + D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, D6895DE828D962C2006341DA /* TimelineLikeController.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6DFC69F242C4CCC00ACC392 /* Weak.swift */, @@ -1454,6 +1459,7 @@ D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */, D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */, D6114E1627F8BB210080E273 /* VersionTests.swift */, + D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */, D6D4DDE6212518A200E1C4BB /* Info.plist */, ); path = TuskerTests; @@ -2006,6 +2012,7 @@ D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */, + D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, @@ -2078,6 +2085,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */, D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */, D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */, D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */, diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 2b2b3a74..02966c72 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -304,7 +304,7 @@ class MastodonController: ObservableObject { run(req) { response in if case .success(let lists, _) = response { DispatchQueue.main.async { - self.lists = lists.sorted(using: ListComparator()) + self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) } } } @@ -314,7 +314,7 @@ class MastodonController: ObservableObject { func addedList(_ list: List) { var new = self.lists new.append(list) - new.sort { $0.title < $1.title } + new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) self.lists = new } @@ -329,7 +329,7 @@ class MastodonController: ObservableObject { if let index = new.firstIndex(where: { $0.id == list.id }) { new[index] = list } - new.sort(using: ListComparator()) + new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) self.lists = new } @@ -367,18 +367,3 @@ class MastodonController: ObservableObject { } } - -private struct ListComparator: SortComparator { - typealias Compared = List - - var underlying = String.Comparator(options: .caseInsensitive) - - var order: SortOrder { - get { underlying.order } - set { underlying.order = newValue } - } - - func compare(_ lhs: List, _ rhs: List) -> ComparisonResult { - return underlying.compare(lhs.title, rhs.title) - } -} diff --git a/Tusker/SemiCaseSensitiveComparator.swift b/Tusker/SemiCaseSensitiveComparator.swift new file mode 100644 index 00000000..ea6e7130 --- /dev/null +++ b/Tusker/SemiCaseSensitiveComparator.swift @@ -0,0 +1,61 @@ +// +// SemiCaseSensitiveComparator.swift +// Tusker +// +// Created by Shadowfacts on 11/30/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import Foundation + +/// A comparator that sorts objects with a string key path case insensitively unless they're the same, in which case uppercase comes after lowercase. +struct SemiCaseSensitiveComparator: SortComparator { + var order: SortOrder = .forward + + typealias Compared = String + + static func keyPath(_ keyPath: KeyPath) -> KeyPathComparator { + return KeyPathComparator(keyPath, comparator: SemiCaseSensitiveComparator()) + } + + func compare(_ lhs: String, _ rhs: String) -> ComparisonResult { + let result = doCompare(lhs, rhs) + if case .reverse = order { + switch result { + case .orderedDescending: + return .orderedAscending + case .orderedAscending: + return .orderedDescending + case .orderedSame: + return .orderedSame + } + } else { + return result + } + } + + private func doCompare(_ lhs: String, _ rhs: String) -> ComparisonResult { + for (l, r) in zip(lhs, rhs) { + let lLower = l.lowercased() + let rLower = r.lowercased() + if lLower < rLower { + return .orderedAscending + } else if lLower > rLower { + return .orderedDescending + } else { + if l < r { + return .orderedDescending + } else if l > r { + return .orderedAscending + } + } + } + if lhs.count > rhs.count { + return .orderedDescending + } else if lhs.count < rhs.count { + return .orderedAscending + } else { + return .orderedSame + } + } +} diff --git a/TuskerTests/SemiCaseSensitiveComparatorTests.swift b/TuskerTests/SemiCaseSensitiveComparatorTests.swift new file mode 100644 index 00000000..d24baa06 --- /dev/null +++ b/TuskerTests/SemiCaseSensitiveComparatorTests.swift @@ -0,0 +1,24 @@ +// +// SemiCaseSensitiveComparatorTests.swift +// TuskerTests +// +// Created by Shadowfacts on 12/1/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import XCTest +@testable import Tusker + +final class SemiCaseSensitiveComparatorTests: XCTestCase { + + func testCompare() { + let comparator = SemiCaseSensitiveComparator() + XCTAssertEqual(comparator.compare("a", "a"), .orderedSame) + XCTAssertEqual(comparator.compare("a", "A"), .orderedAscending) + XCTAssertEqual(comparator.compare("A", "a"), .orderedDescending) + XCTAssertEqual(comparator.compare("a", "B"), .orderedAscending) + XCTAssertEqual(comparator.compare("b", "A"), .orderedDescending) + XCTAssertEqual(["TEST", "Test", "test"].sorted(using: comparator), ["test", "Test", "TEST"]) + } + +}