diff --git a/Sources/MusicAssistantApp/Presentation/InstrumentBrowserView.swift b/Sources/MusicAssistantApp/Presentation/InstrumentBrowserView.swift index 5e6fae5..da0a485 100644 --- a/Sources/MusicAssistantApp/Presentation/InstrumentBrowserView.swift +++ b/Sources/MusicAssistantApp/Presentation/InstrumentBrowserView.swift @@ -5,6 +5,7 @@ struct InstrumentBrowserView: View { private let catalog: LocalInstrumentCatalog @State private var searchText = "" + @State private var selectedFamilyCategory: String? @Environment(\.dismiss) private var dismiss init(catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()) { @@ -13,23 +14,44 @@ struct InstrumentBrowserView: View { var body: some View { NavigationStack { - List(catalog.instruments(matching: searchText), id: \.id) { instrument in - VStack(alignment: .leading, spacing: 4) { - Text(instrument.name) - .font(.body.weight(.medium)) + VStack(spacing: 0) { + Picker("Family", selection: $selectedFamilyCategory) { + Text("All families").tag(String?.none) - HStack(spacing: 6) { - Text(instrument.familyCategory) - - if let regionOrigin = instrument.regionOrigin { - Text(regionOrigin) - } + ForEach(catalog.familyCategories, id: \.self) { familyCategory in + Text(familyCategory).tag(Optional(familyCategory)) } - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) } - .padding(.vertical, 2) + .pickerStyle(.menu) + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + List( + catalog.instruments( + matching: searchText, + familyCategory: selectedFamilyCategory + ), + id: \.id + ) { instrument in + VStack(alignment: .leading, spacing: 4) { + Text(instrument.name) + .font(.body.weight(.medium)) + + HStack(spacing: 6) { + Text(instrument.familyCategory) + + if let regionOrigin = instrument.regionOrigin { + Text(regionOrigin) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 2) + } } .navigationTitle("Instrument Catalog") .searchable(text: $searchText, prompt: "Search instruments") diff --git a/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift b/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift index feacf46..d23a750 100644 --- a/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift +++ b/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift @@ -7,14 +7,25 @@ public struct LocalInstrumentCatalog: Sendable { self.items = items } - public func instruments(matching query: String) -> [InstrumentCatalogItem] { + public var familyCategories: [String] { + Array(Set(items.map(\.familyCategory))) + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + public func instruments( + matching query: String, + familyCategory: String? = nil + ) -> [InstrumentCatalogItem] { let searchTokens = Self.searchTokens(for: query) + let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines) return items .filter { item in - searchTokens.isEmpty || searchTokens.allSatisfy { token in + let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in Self.searchableTerms(for: item).contains { $0.contains(token) } } + let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory + return matchesSearch && matchesFamily } .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } diff --git a/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift b/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift index 1c03f55..48b6a44 100644 --- a/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift +++ b/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift @@ -34,4 +34,17 @@ final class LocalInstrumentCatalogTests: XCTestCase { XCTAssertEqual(catalog.instruments(matching: "nylon string").map(\.id), ["classical-guitar"]) XCTAssertEqual(catalog.instruments(matching: "عود").map(\.id), ["oud"]) } + + func testFamilyCategoriesAndFilteringComeFromCatalogMetadata() { + let catalog = LocalInstrumentCatalog() + let pluckedStrings = catalog.instruments(matching: "", familyCategory: "Plucked strings") + + XCTAssertTrue(catalog.familyCategories.contains("Plucked strings")) + XCTAssertTrue(pluckedStrings.contains { $0.id == "oud" }) + XCTAssertTrue(pluckedStrings.allSatisfy { $0.familyCategory == "Plucked strings" }) + XCTAssertTrue( + catalog.instruments(matching: "guitar", familyCategory: "Plucked strings") + .allSatisfy { $0.familyCategory == "Plucked strings" } + ) + } } diff --git a/docs/TASKS.md b/docs/TASKS.md index 1dd27f8..2c3445f 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -83,7 +83,7 @@ requirement is missing and blocks implementation, record it in instruments. - [x] Keep catalog data out of SwiftUI views. - [x] Build searchable instrument browser. -- [ ] Add browsing/filtering by family/category. +- [x] Add browsing/filtering by family/category. - [ ] Add browsing/filtering by region/origin where useful. - [ ] Add checkbox-based multi-select and deselect behavior. - [ ] Persist selected instruments on the current Song Project.