diff --git a/Sources/MusicAssistantApp/Presentation/InstrumentBrowserView.swift b/Sources/MusicAssistantApp/Presentation/InstrumentBrowserView.swift new file mode 100644 index 0000000..5e6fae5 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/InstrumentBrowserView.swift @@ -0,0 +1,50 @@ +import MusicAssistantCore +import SwiftUI + +struct InstrumentBrowserView: View { + private let catalog: LocalInstrumentCatalog + + @State private var searchText = "" + @Environment(\.dismiss) private var dismiss + + init(catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()) { + self.catalog = catalog + } + + 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)) + + 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") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + dismiss() + } + } + } + } + .frame(minWidth: 460, minHeight: 520) + } +} + +#Preview { + InstrumentBrowserView() +} diff --git a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift index cc025c9..8ec9912 100644 --- a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift +++ b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift @@ -9,6 +9,7 @@ struct ProjectInspectorView: View { @State private var newInstrumentName = "" @State private var newVocalistLabel = "" @State private var newLanguageName = "" + @State private var isInstrumentBrowserPresented = false @State private var isSaving = false @State private var saveStatus: SaveStatus? @@ -62,6 +63,12 @@ struct ProjectInspectorView: View { } Section("Instruments") { + Button { + isInstrumentBrowserPresented = true + } label: { + Label("Browse catalog", systemImage: "magnifyingglass") + } + ForEach($project.instruments, id: \.instrumentId) { $instrument in Toggle(isOn: $instrument.selected) { Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId) @@ -138,6 +145,9 @@ struct ProjectInspectorView: View { } } .background(Color(nsColor: .windowBackgroundColor)) + .sheet(isPresented: $isInstrumentBrowserPresented) { + InstrumentBrowserView() + } } private var durationSecondsBinding: Binding { diff --git a/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift b/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift index a78ae32..feacf46 100644 --- a/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift +++ b/Sources/MusicAssistantCore/Services/InstrumentCatalog/LocalInstrumentCatalog.swift @@ -1,3 +1,5 @@ +import Foundation + public struct LocalInstrumentCatalog: Sendable { public let items: [InstrumentCatalogItem] @@ -5,6 +7,18 @@ public struct LocalInstrumentCatalog: Sendable { self.items = items } + public func instruments(matching query: String) -> [InstrumentCatalogItem] { + let searchTokens = Self.searchTokens(for: query) + + return items + .filter { item in + searchTokens.isEmpty || searchTokens.allSatisfy { token in + Self.searchableTerms(for: item).contains { $0.contains(token) } + } + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + public static let worldwideItems: [InstrumentCatalogItem] = western + middleEastern + african + southAsian + eastAsian + southeastAsian + latinAmerican + folkAndModern @@ -281,4 +295,23 @@ public struct LocalInstrumentCatalog: Sendable { searchTerms: terms ) } + + private static func searchableTerms(for item: InstrumentCatalogItem) -> [String] { + searchTokens(for: [item.name] + item.aliases + item.searchTerms) + } + + private static func searchTokens(for query: String) -> [String] { + searchTokens(for: [query]) + } + + private static func searchTokens(for values: [String]) -> [String] { + values + .joined(separator: " ") + .folding( + options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: Locale(identifier: "en_US_POSIX") + ) + .split(whereSeparator: { $0.isWhitespace || $0 == "-" }) + .map(String.init) + } } diff --git a/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift b/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift index c4bba8a..1c03f55 100644 --- a/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift +++ b/Tests/MusicAssistantCoreTests/LocalInstrumentCatalogTests.swift @@ -26,4 +26,12 @@ final class LocalInstrumentCatalogTests: XCTestCase { XCTAssertEqual(Set(items.map(\.id)).count, items.count) XCTAssertTrue(items.allSatisfy { !$0.name.isEmpty && !$0.familyCategory.isEmpty }) } + + func testSearchMatchesNamesAliasesAndArabicSearchTerms() { + let catalog = LocalInstrumentCatalog() + + XCTAssertEqual(catalog.instruments(matching: "concert flute").map(\.id), ["flute"]) + XCTAssertEqual(catalog.instruments(matching: "nylon string").map(\.id), ["classical-guitar"]) + XCTAssertEqual(catalog.instruments(matching: "عود").map(\.id), ["oud"]) + } } diff --git a/docs/TASKS.md b/docs/TASKS.md index 8e58922..1dd27f8 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -82,7 +82,7 @@ requirement is missing and blocks implementation, record it in American, traditional, folk, orchestral, electronic and modern instruments. - [x] Keep catalog data out of SwiftUI views. -- [ ] Build searchable instrument browser. +- [x] Build searchable instrument browser. - [ ] Add browsing/filtering by family/category. - [ ] Add browsing/filtering by region/origin where useful. - [ ] Add checkbox-based multi-select and deselect behavior.