Add searchable instrument browser

This commit is contained in:
diyaa
2026-09-14 00:55:41 +02:00
parent b2bf04e78b
commit ecbf07d1ea
5 changed files with 102 additions and 1 deletions
@@ -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()
}
@@ -9,6 +9,7 @@ struct ProjectInspectorView: View {
@State private var newInstrumentName = "" @State private var newInstrumentName = ""
@State private var newVocalistLabel = "" @State private var newVocalistLabel = ""
@State private var newLanguageName = "" @State private var newLanguageName = ""
@State private var isInstrumentBrowserPresented = false
@State private var isSaving = false @State private var isSaving = false
@State private var saveStatus: SaveStatus? @State private var saveStatus: SaveStatus?
@@ -62,6 +63,12 @@ struct ProjectInspectorView: View {
} }
Section("Instruments") { Section("Instruments") {
Button {
isInstrumentBrowserPresented = true
} label: {
Label("Browse catalog", systemImage: "magnifyingglass")
}
ForEach($project.instruments, id: \.instrumentId) { $instrument in ForEach($project.instruments, id: \.instrumentId) { $instrument in
Toggle(isOn: $instrument.selected) { Toggle(isOn: $instrument.selected) {
Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId) Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId)
@@ -138,6 +145,9 @@ struct ProjectInspectorView: View {
} }
} }
.background(Color(nsColor: .windowBackgroundColor)) .background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $isInstrumentBrowserPresented) {
InstrumentBrowserView()
}
} }
private var durationSecondsBinding: Binding<String> { private var durationSecondsBinding: Binding<String> {
@@ -1,3 +1,5 @@
import Foundation
public struct LocalInstrumentCatalog: Sendable { public struct LocalInstrumentCatalog: Sendable {
public let items: [InstrumentCatalogItem] public let items: [InstrumentCatalogItem]
@@ -5,6 +7,18 @@ public struct LocalInstrumentCatalog: Sendable {
self.items = items 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] = public static let worldwideItems: [InstrumentCatalogItem] =
western + middleEastern + african + southAsian + eastAsian + southeastAsian + latinAmerican + folkAndModern western + middleEastern + african + southAsian + eastAsian + southeastAsian + latinAmerican + folkAndModern
@@ -281,4 +295,23 @@ public struct LocalInstrumentCatalog: Sendable {
searchTerms: terms 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)
}
} }
@@ -26,4 +26,12 @@ final class LocalInstrumentCatalogTests: XCTestCase {
XCTAssertEqual(Set(items.map(\.id)).count, items.count) XCTAssertEqual(Set(items.map(\.id)).count, items.count)
XCTAssertTrue(items.allSatisfy { !$0.name.isEmpty && !$0.familyCategory.isEmpty }) 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"])
}
} }
+1 -1
View File
@@ -82,7 +82,7 @@ requirement is missing and blocks implementation, record it in
American, traditional, folk, orchestral, electronic and modern American, traditional, folk, orchestral, electronic and modern
instruments. instruments.
- [x] Keep catalog data out of SwiftUI views. - [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 family/category.
- [ ] Add browsing/filtering by region/origin where useful. - [ ] Add browsing/filtering by region/origin where useful.
- [ ] Add checkbox-based multi-select and deselect behavior. - [ ] Add checkbox-based multi-select and deselect behavior.