From b1d516984a34313b17c4641b4294ad13fd5dc52b Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 30 Jan 2026 14:47:30 -0800 Subject: [PATCH 1/4] add force rescanning --- abledex/AppState.swift | 102 +++++++++++++++++- abledex/Scanner/ALSParser.swift | 78 +++++++++++--- abledex/Scanner/ProjectScanner.swift | 38 ++++++- .../Views/ProjectList/ProjectTableView.swift | 10 ++ abledex/Views/Sidebar/SidebarView.swift | 13 +++ 5 files changed, 225 insertions(+), 16 deletions(-) diff --git a/abledex/AppState.swift b/abledex/AppState.swift index a7d6788..ac412bd 100644 --- a/abledex/AppState.swift +++ b/abledex/AppState.swift @@ -485,7 +485,7 @@ final class AppState { // MARK: - Scanning - func startScan() async { + func startScan(forceReparse: Bool = false) async { guard !isScanning else { return } isScanning = true scanProgress = .starting @@ -494,7 +494,7 @@ final class AppState { let scanner = self.scanner let result: Result = await Task.detached(priority: .userInitiated) { do { - let count = try await scanner.scanAllLocations { progress in + let count = try await scanner.scanAllLocations(forceReparse: forceReparse) { progress in Task { @MainActor in // Use weak reference pattern inline await MainActor.run { [weak self] in @@ -519,6 +519,104 @@ final class AppState { isScanning = false } + func startLocationScan(_ location: LocationRecord) async { + guard !isScanning else { return } + isScanning = true + scanProgress = .starting + + let scanner = self.scanner + let result: Result = await Task.detached(priority: .userInitiated) { + do { + let count = try await scanner.scanLocation(location, forceReparse: true) { progress in + Task { @MainActor in + await MainActor.run { [weak self] in + self?.scanProgress = progress + } + } + } + return .success(count) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success: + await loadData() + case .failure(let error): + print("Location scan failed: \(error)") + scanProgress = .failed(error) + } + + isScanning = false + } + + func rescanProject(_ project: ProjectRecord) async { + guard !isScanning else { return } + isScanning = true + scanProgress = .parsing(current: 1, total: 1, projectName: project.name) + + let scanner = self.scanner + let alsPath = project.alsFilePath + let result: Result = await Task.detached(priority: .userInitiated) { + do { + let record = try await scanner.scanSingleProject(alsFilePath: alsPath) + return .success(record) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success(let record): + if let record = record, let index = projects.firstIndex(where: { $0.id == record.id }) { + projects[index] = record + } else { + await loadData() + } + scanProgress = .completed(projectCount: 1, duration: 0) + case .failure(let error): + print("Project rescan failed: \(error)") + scanProgress = .failed(error) + } + + isScanning = false + } + + func rescanProjects(_ projectsToRescan: [ProjectRecord]) async { + guard !isScanning else { return } + isScanning = true + scanProgress = .starting + + let scanner = self.scanner + let total = projectsToRescan.count + + var scannedCount = 0 + for project in projectsToRescan { + scannedCount += 1 + scanProgress = .parsing(current: scannedCount, total: total, projectName: project.name) + + let alsPath = project.alsFilePath + let result: Result = await Task.detached(priority: .userInitiated) { + do { + let record = try await scanner.scanSingleProject(alsFilePath: alsPath) + return .success(record) + } catch { + return .failure(error) + } + }.value + + if case .success(let record) = result, + let record = record, + let index = projects.firstIndex(where: { $0.id == record.id }) { + projects[index] = record + } + } + + scanProgress = .completed(projectCount: total, duration: 0) + isScanning = false + } + func addLocation(path: String) async throws { let url = URL(fileURLWithPath: path) let displayName = url.lastPathComponent diff --git a/abledex/Scanner/ALSParser.swift b/abledex/Scanner/ALSParser.swift index 244df5c..6e0507e 100644 --- a/abledex/Scanner/ALSParser.swift +++ b/abledex/Scanner/ALSParser.swift @@ -292,28 +292,82 @@ struct ALSParser: Sendable { private nonisolated func extractPlugins(from xmlString: String) -> [String] { var plugins: Set = [] - let pattern = #" nearby + extractPluginsByTag(from: xmlString, openTag: " nearby + extractPluginsByTag(from: xmlString, openTag: " + extractPluginsByTag(from: xmlString, openTag: " (older format) + extractPluginsByTag(from: xmlString, openTag: " + ) { + var searchStart = xmlString.startIndex + + for _ in 0..<200 { // Safety limit + guard let tagRange = xmlString.range(of: openTag, range: searchStart.. Bool { + guard !name.isEmpty, name != "None" else { return false } + return !isBuiltInDevice(name) } private nonisolated func isBuiltInDevice(_ name: String) -> Bool { - let prefixes = ["Ableton", "Audio", "Auto", "Beat", "Corpus", "Delay", "Drum", "EQ", "External", "Filter", "Flanger", "Gate", "Glue", "Grain", "Limiter", "Looper", "MIDI", "Multiband", "Overdrive", "Pedal", "Phaser", "Pitch", "Redux", "Resonator", "Reverb", "Saturator", "Scale", "Simple", "Spectrum", "Tension", "Tuner", "Utility", "Vinyl", "Vocoder", "Wavetable"] + let prefixes = ["Ableton", "Audio", "Auto", "Beat", "Corpus", "Delay", "Drum", + "EQ", "External", "Filter", "Flanger", "Gate", "Glue", "Grain", + "Limiter", "Looper", "MIDI", "Multiband", "Overdrive", "Pedal", + "Phaser", "Pitch", "Redux", "Resonator", "Reverb", "Saturator", + "Scale", "Simple", "Spectrum", "Tension", "Tuner", "Utility", + "Vinyl", "Vocoder", "Wavetable"] return prefixes.contains { name.hasPrefix($0) } } diff --git a/abledex/Scanner/ProjectScanner.swift b/abledex/Scanner/ProjectScanner.swift index 3bb893d..cacc3f7 100644 --- a/abledex/Scanner/ProjectScanner.swift +++ b/abledex/Scanner/ProjectScanner.swift @@ -40,6 +40,7 @@ final class ProjectScanner: Sendable { } func scanAllLocations( + forceReparse: Bool = false, progress: @escaping @Sendable (ScanProgress) -> Void ) async throws -> Int { let startTime = Date() @@ -51,7 +52,7 @@ final class ProjectScanner: Sendable { let totalProjects = try await withThrowingTaskGroup(of: Int.self) { group in for location in locations { group.addTask { - try await self.scanLocation(location, progress: progress) + try await self.scanLocation(location, forceReparse: forceReparse, progress: progress) } } @@ -70,6 +71,7 @@ final class ProjectScanner: Sendable { func scanLocation( _ location: LocationRecord, + forceReparse: Bool = false, progress: @escaping @Sendable (ScanProgress) -> Void ) async throws -> Int { let locationURL = URL(fileURLWithPath: location.path) @@ -104,7 +106,8 @@ final class ProjectScanner: Sendable { let existing = existingProjects[discovered.alsFilePath.path] // Skip files that haven't changed since last index - if let existing = existing, + if !forceReparse, + let existing = existing, abs(existing.filesystemModifiedDate.timeIntervalSince(discovered.modifiedDate)) < 1.0 { continue } @@ -147,6 +150,37 @@ final class ProjectScanner: Sendable { return processed } + func scanSingleProject(alsFilePath: String) async throws -> ProjectRecord? { + let url = URL(fileURLWithPath: alsFilePath) + let folderURL = url.deletingLastPathComponent() + + guard FileManager.default.fileExists(atPath: alsFilePath) else { return nil } + + let attrs = try FileManager.default.attributesOfItem(atPath: alsFilePath) + let modifiedDate = (attrs[.modificationDate] as? Date) ?? Date() + let createdDate = (attrs[.creationDate] as? Date) ?? modifiedDate + + let projectName = url.deletingPathExtension().lastPathComponent + let sourceVolume = FileSystemCrawler.volumeName(for: url) + + let discovered = DiscoveredProject( + folderPath: folderURL, + alsFilePath: url, + projectName: projectName, + sourceVolume: sourceVolume, + createdDate: createdDate, + modifiedDate: modifiedDate + ) + + let existingProjects = try await database.fetchProjects(byAlsFilePaths: [alsFilePath]) + let existing = existingProjects[alsFilePath] + + guard let record = parseProject(discovered, existing: existing) else { return nil } + + try await database.saveProjects([record]) + return record + } + private nonisolated func parseProject(_ discovered: DiscoveredProject, existing: ProjectRecord?) -> ProjectRecord? { do { let parsedData = try ALSParser().parse(alsFilePath: discovered.alsFilePath) diff --git a/abledex/Views/ProjectList/ProjectTableView.swift b/abledex/Views/ProjectList/ProjectTableView.swift index 2bc34a4..69fa316 100644 --- a/abledex/Views/ProjectList/ProjectTableView.swift +++ b/abledex/Views/ProjectList/ProjectTableView.swift @@ -336,6 +336,11 @@ struct ProjectTableView: View { } } + Button("Re-scan \(appState.selectedProjectIDs.count) Projects") { + Task { + await appState.rescanProjects(appState.selectedProjects) + } + } Divider() Button("Remove \(appState.selectedProjectIDs.count) Projects from Library", role: .destructive) { showDeleteConfirmation = true @@ -379,6 +384,11 @@ struct ProjectTableView: View { } } + Button("Re-scan Project") { + Task { + await appState.rescanProject(project) + } + } Divider() Button("Copy Path") { NSPasteboard.general.clearContents() diff --git a/abledex/Views/Sidebar/SidebarView.swift b/abledex/Views/Sidebar/SidebarView.swift index 3b63113..51edb1f 100644 --- a/abledex/Views/Sidebar/SidebarView.swift +++ b/abledex/Views/Sidebar/SidebarView.swift @@ -432,6 +432,11 @@ struct SidebarView: View { Image(systemName: location.isAutoDetected ? "folder.fill" : "folder.badge.person.crop") } .contextMenu { + Button("Scan Location") { + Task { + await appState.startLocationScan(location) + } + } Button("Reveal in Finder") { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: location.path) } @@ -495,6 +500,14 @@ struct SidebarView: View { } .buttonStyle(.borderedProminent) .disabled(appState.isScanning) + .contextMenu { + Button("Force Re-scan All") { + Task { + await appState.startScan(forceReparse: true) + } + } + .disabled(appState.isScanning) + } .padding(.horizontal) } .padding(.vertical, 8) From 10765b61b263d3ee7b3570e15244a9c73c463282 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 30 Jan 2026 14:56:49 -0800 Subject: [PATCH 2/4] update fix --- abledex.xcodeproj/project.pbxproj | 4 ++-- abledex/Services/UpdateService.swift | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/abledex.xcodeproj/project.pbxproj b/abledex.xcodeproj/project.pbxproj index 3fa2831..e0a5538 100644 --- a/abledex.xcodeproj/project.pbxproj +++ b/abledex.xcodeproj/project.pbxproj @@ -339,7 +339,7 @@ CODE_SIGN_ENTITLEMENTS = abledex/abledex.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; @@ -375,7 +375,7 @@ CODE_SIGN_ENTITLEMENTS = abledex/abledex.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 94XUGF9CU7; ENABLE_APP_SANDBOX = NO; diff --git a/abledex/Services/UpdateService.swift b/abledex/Services/UpdateService.swift index 81bbdf5..eb30e4f 100644 --- a/abledex/Services/UpdateService.swift +++ b/abledex/Services/UpdateService.swift @@ -32,6 +32,11 @@ final class UpdateService { var currentBuild: String { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" } + + var currentFullVersion: String { + // full version = version.build + "\(currentVersion).\(currentBuild)" + } private init() {} @@ -88,7 +93,7 @@ final class UpdateService { var request = URLRequest(url: url) request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - request.setValue("Abledex/\(currentVersion)", forHTTPHeaderField: "User-Agent") + request.setValue("Abledex/\(currentFullVersion)", forHTTPHeaderField: "User-Agent") do { let (data, response) = try await URLSession.shared.data(for: request) @@ -133,7 +138,7 @@ final class UpdateService { } // Compare versions - updateAvailable = isNewerVersion(versionString, than: currentVersion) + updateAvailable = isNewerVersion(versionString, than: currentFullVersion) } catch { errorMessage = "Failed to check for updates: \(error.localizedDescription)" From 5b41c01c0c2c7016a105d741c815b4f6000ba58f Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 30 Jan 2026 20:32:22 -0800 Subject: [PATCH 3/4] perf --- abledex/AppState.swift | 156 ++++++++++++------ abledex/Database/ProjectRecord.swift | 56 +++++-- abledex/Scanner/ALSParser.swift | 124 +++++++++----- abledex/Scanner/AudioPreviewService.swift | 7 +- .../Services/DuplicateDetectionService.swift | 54 ++---- .../Views/ProjectList/ProjectDetailView.swift | 20 ++- 6 files changed, 261 insertions(+), 156 deletions(-) diff --git a/abledex/AppState.swift b/abledex/AppState.swift index ac412bd..5a5a04f 100644 --- a/abledex/AppState.swift +++ b/abledex/AppState.swift @@ -23,13 +23,14 @@ final class AppState { var projects: [ProjectRecord] = [] { didSet { - // Skip if this is the initial load (caches already populated) - guard !isInitialLoad else { return } + // Skip if this is the initial load or batch update in progress + guard !isInitialLoad, !isBatchUpdating else { return } recomputeCachedCounts() recomputeFilteredProjects() } } private var isInitialLoad = true + private var isBatchUpdating = false var locations: [LocationRecord] = [] var selectedProjectIDs: Set = [] var searchQuery: String = "" { @@ -64,6 +65,8 @@ final class AppState { private(set) var cachedProjectsByFolder: [String: [ProjectRecord]] = [:] private(set) var cachedDuplicateGroups: [DuplicateGroup] = [] private(set) var cachedDuplicatesCount: Int = 0 + private(set) var cachedDuplicateProjectIDs: Set = [] // O(1) lookup for detail view + private var duplicateDebounceTask: Task? private func recomputeCachedCounts() { var newStatusCounts: [CompletionStatus: Int] = [:] @@ -120,21 +123,27 @@ final class AppState { cachedFoldersWithMultipleVersions = newFolderCounts.filter { $0.value > 1 }.keys.sorted() cachedProjectsByFolder = Dictionary(grouping: projects, by: { $0.projectFolderName }) - // Recompute duplicates in background (O(n²) operation) - let projectsSnapshot = self.projects - Task.detached(priority: .utility) { - let groups = await DuplicateDetectionService().findDuplicates(in: projectsSnapshot) - let count = Set(groups.flatMap { $0.projects.map { $0.id } }).count - await MainActor.run { [groups, count] in - // Update state on the main actor - // Accessing self here is safe as this closure executes on MainActor - // and we didn't capture self in the detached task - let update: @MainActor () -> Void = { - self.cachedDuplicateGroups = groups - self.cachedDuplicatesCount = count - } - update() - } + // Debounced duplicate detection — avoids re-running O(n²) on every keystroke/edit + scheduleDuplicateRecomputation() + } + + private func scheduleDuplicateRecomputation() { + duplicateDebounceTask?.cancel() + duplicateDebounceTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + + let projectsSnapshot = self.projects + let result = await Task.detached(priority: .utility) { + let groups = DuplicateDetectionService().findDuplicates(in: projectsSnapshot) + let ids = Set(groups.flatMap { $0.projects.map { $0.id } }) + return (groups, ids) + }.value + + guard !Task.isCancelled else { return } + self.cachedDuplicateGroups = result.0 + self.cachedDuplicateProjectIDs = result.1 + self.cachedDuplicatesCount = result.1.count } } @@ -312,7 +321,7 @@ final class AppState { } func versionsInSameFolder(as project: ProjectRecord) -> [ProjectRecord] { - projects.filter { $0.folderPath == project.folderPath } + (cachedProjectsByFolder[project.projectFolderName] ?? []) .sorted { ($0.modifiedDate ?? $0.filesystemModifiedDate) < ($1.modifiedDate ?? $1.filesystemModifiedDate) } } @@ -325,11 +334,17 @@ final class AppState { } func hasDuplicates(_ project: ProjectRecord) -> Bool { - duplicateService.hasDuplicates(project, in: projects) + cachedDuplicateProjectIDs.contains(project.id) } func duplicatesOf(_ project: ProjectRecord) -> [ProjectRecord] { - duplicateService.duplicatesOf(project, in: projects) + // Use cached groups for O(1) lookup instead of O(n) scan + for group in cachedDuplicateGroups { + if group.projects.contains(where: { $0.id == project.id }) { + return group.projects.filter { $0.id != project.id } + } + } + return [] } var projectCount: Int { @@ -386,6 +401,7 @@ final class AppState { cachedFilteredProjects = caches.filteredProjects cachedDuplicateGroups = caches.duplicateGroups cachedDuplicatesCount = caches.duplicatesCount + cachedDuplicateProjectIDs = caches.duplicateProjectIDs // Set projects (didSet skipped due to isInitialLoad flag) projects = fetchedProjects @@ -417,6 +433,7 @@ final class AppState { var filteredProjects: [ProjectRecord] var duplicateGroups: [DuplicateGroup] var duplicatesCount: Int + var duplicateProjectIDs: Set } private nonisolated func computeCachesOffMainThread(for projects: [ProjectRecord]) -> ComputedCaches { @@ -445,7 +462,7 @@ final class AppState { // Compute duplicates (O(n²) but done off main thread) let duplicateGroups = DuplicateDetectionService().findDuplicates(in: projects) - let duplicatesCount = Set(duplicateGroups.flatMap { $0.projects.map { $0.id } }).count + let duplicateProjectIDs = Set(duplicateGroups.flatMap { $0.projects.map { $0.id } }) return ComputedCaches( statusCounts: statusCounts, @@ -464,7 +481,8 @@ final class AppState { projectsByFolder: Dictionary(grouping: projects, by: { $0.projectFolderName }), filteredProjects: sortedProjects, duplicateGroups: duplicateGroups, - duplicatesCount: duplicatesCount + duplicatesCount: duplicateProjectIDs.count, + duplicateProjectIDs: duplicateProjectIDs ) } @@ -591,6 +609,9 @@ final class AppState { let scanner = self.scanner let total = projectsToRescan.count + // Suppress recomputation during the loop, trigger once at end + isBatchUpdating = true + var scannedCount = 0 for project in projectsToRescan { scannedCount += 1 @@ -613,6 +634,10 @@ final class AppState { } } + isBatchUpdating = false + recomputeCachedCounts() + recomputeFilteredProjects() + scanProgress = .completed(projectCount: total, duration: 0) isScanning = false } @@ -746,57 +771,81 @@ final class AppState { // MARK: - Batch Operations + /// Performs a batch update: collects all mutations, saves to DB in one transaction, + /// updates the projects array once, then recomputes caches once. + private func performBatchUpdate(_ mutate: (inout [ProjectRecord]) -> [ProjectRecord]) async throws { + var mutableProjects = projects + let updatedRecords = mutate(&mutableProjects) + + guard !updatedRecords.isEmpty else { return } + + // Save all changes in a single DB transaction + try await database.saveProjects(updatedRecords) + + // Apply to array with recomputation suppressed, then trigger once + isBatchUpdating = true + projects = mutableProjects + isBatchUpdating = false + + recomputeCachedCounts() + recomputeFilteredProjects() + } + func batchSetStatus(_ status: CompletionStatus) async throws { - for id in selectedProjectIDs { - if var project = projects.first(where: { $0.id == id }) { - project.completionStatus = status - try await database.saveProject(project) + try await performBatchUpdate { projects in + var updated: [ProjectRecord] = [] + for id in selectedProjectIDs { if let index = projects.firstIndex(where: { $0.id == id }) { - projects[index] = project + projects[index].completionStatus = status + updated.append(projects[index]) } } + return updated } } func batchAddTag(_ tag: String) async throws { - for id in selectedProjectIDs { - if var project = projects.first(where: { $0.id == id }) { - if !project.userTags.contains(tag) { - var tags = project.userTags - tags.append(tag) - project.userTags = tags - try await database.saveProject(project) - if let index = projects.firstIndex(where: { $0.id == id }) { - projects[index] = project + try await performBatchUpdate { projects in + var updated: [ProjectRecord] = [] + for id in selectedProjectIDs { + if let index = projects.firstIndex(where: { $0.id == id }) { + if !projects[index].userTags.contains(tag) { + var tags = projects[index].userTags + tags.append(tag) + projects[index].userTags = tags + updated.append(projects[index]) } } } + return updated } } func batchRemoveTag(_ tag: String) async throws { - for id in selectedProjectIDs { - if var project = projects.first(where: { $0.id == id }) { - var tags = project.userTags - tags.removeAll { $0 == tag } - project.userTags = tags - try await database.saveProject(project) + try await performBatchUpdate { projects in + var updated: [ProjectRecord] = [] + for id in selectedProjectIDs { if let index = projects.firstIndex(where: { $0.id == id }) { - projects[index] = project + var tags = projects[index].userTags + tags.removeAll { $0 == tag } + projects[index].userTags = tags + updated.append(projects[index]) } } + return updated } } func batchToggleFavorite(_ setFavorite: Bool) async throws { - for id in selectedProjectIDs { - if var project = projects.first(where: { $0.id == id }) { - project.isFavorite = setFavorite - try await database.saveProject(project) + try await performBatchUpdate { projects in + var updated: [ProjectRecord] = [] + for id in selectedProjectIDs { if let index = projects.firstIndex(where: { $0.id == id }) { - projects[index] = project + projects[index].isFavorite = setFavorite + updated.append(projects[index]) } } + return updated } } @@ -811,14 +860,15 @@ final class AppState { } func batchSetColorLabel(_ colorLabel: ColorLabel) async throws { - for id in selectedProjectIDs { - if var project = projects.first(where: { $0.id == id }) { - project.colorLabel = colorLabel - try await database.saveProject(project) + try await performBatchUpdate { projects in + var updated: [ProjectRecord] = [] + for id in selectedProjectIDs { if let index = projects.firstIndex(where: { $0.id == id }) { - projects[index] = project + projects[index].colorLabel = colorLabel + updated.append(projects[index]) } } + return updated } } diff --git a/abledex/Database/ProjectRecord.swift b/abledex/Database/ProjectRecord.swift index a8b7821..a228b38 100644 --- a/abledex/Database/ProjectRecord.swift +++ b/abledex/Database/ProjectRecord.swift @@ -187,42 +187,62 @@ struct ProjectRecord: Codable, Sendable, Identifiable, FetchableRecord, Persista // MARK: - JSON Decoding Cache -/// Thread-safe cache for decoded JSON arrays to avoid repeated decoding +/// Thread-safe LRU cache for decoded JSON arrays to avoid repeated decoding private final class JSONDecodeCache: @unchecked Sendable { static let shared = JSONDecodeCache() + private static let maxEntries = 2000 + private var cache: [String: [String]] = [:] + private var accessOrder: [String] = [] // LRU tracking: most recent at end private let lock = NSLock() - - func get(_ json: String?) -> [String]? { - guard let json = json else { return nil } - lock.lock() - defer { lock.unlock() } - return cache[json] - } - - func set(_ json: String, value: [String]) { - lock.lock() - defer { lock.unlock() } - cache[json] = value - } + private let decoder = JSONDecoder() func decode(_ json: String?) -> [String] { guard let json = json else { return [] } + lock.lock() + // Check cache first - if let cached = get(json) { + if let cached = cache[json] { + // Move to end (most recently used) + if let idx = accessOrder.lastIndex(of: json) { + accessOrder.remove(at: idx) + } + accessOrder.append(json) + lock.unlock() return cached } - // Decode and cache + lock.unlock() + + // Decode outside lock to avoid holding it during JSON parsing guard let data = json.data(using: .utf8), - let decoded = try? JSONDecoder().decode([String].self, from: data) else { + let decoded = try? decoder.decode([String].self, from: data) else { return [] } - set(json, value: decoded) + + lock.lock() + cache[json] = decoded + accessOrder.append(json) + + // Evict oldest entries if over capacity + while cache.count > Self.maxEntries { + let oldest = accessOrder.removeFirst() + cache.removeValue(forKey: oldest) + } + + lock.unlock() return decoded } + + /// Clear cache (e.g., after a full re-scan) + func clear() { + lock.lock() + cache.removeAll() + accessOrder.removeAll() + lock.unlock() + } } // MARK: - Convenience accessors for JSON fields diff --git a/abledex/Scanner/ALSParser.swift b/abledex/Scanner/ALSParser.swift index 6e0507e..3b9863e 100644 --- a/abledex/Scanner/ALSParser.swift +++ b/abledex/Scanner/ALSParser.swift @@ -71,7 +71,28 @@ enum ALSParserError: Error, LocalizedError { } } +// Cached regex patterns — compiled once at launch, reused across all parse calls +private enum ALSRegex { + static let timeSignatureNumerator = try! NSRegularExpression( + pattern: #"[^<]*<[^>]*Numerator Value="(\d+)""# + ) + static let timeSignatureDenominator = try! NSRegularExpression( + pattern: #"[^<]*<[^>]*Denominator Value="(\d+)""# + ) + static let currentEnd = try! NSRegularExpression( + pattern: #"\s*\s* ParsedProjectData { @@ -116,7 +137,7 @@ struct ALSParser: Sendable { return data } - // Skip gzip header and decompress + // Skip gzip header to find deflate payload var headerLength = 10 let bytes = [UInt8](data) @@ -150,25 +171,65 @@ struct ALSParser: Sendable { } let deflateData = data.subdata(in: headerLength..<(data.count - 8)) - let destinationBufferSize = min(data.count * 30, 100_000_000) // Cap at 100MB - var destinationBuffer = [UInt8](repeating: 0, count: destinationBufferSize) - - let decompressedSize = deflateData.withUnsafeBytes { sourceBuffer in - compression_decode_buffer( - &destinationBuffer, - destinationBufferSize, - sourceBuffer.bindMemory(to: UInt8.self).baseAddress!, - deflateData.count, - nil, - COMPRESSION_ZLIB - ) + + // Streaming decompression — grows buffer as needed instead of pre-allocating 100MB + let streamPtr = UnsafeMutablePointer.allocate(capacity: 1) + streamPtr.initialize(to: compression_stream( + dst_ptr: UnsafeMutablePointer.allocate(capacity: 0), + dst_size: 0, + src_ptr: UnsafePointer(bitPattern: 1)!, + src_size: 0, + state: nil + )) + let initStatus = compression_stream_init(streamPtr, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB) + guard initStatus == COMPRESSION_STATUS_OK else { + streamPtr.deallocate() + throw ALSParserError.decompressionFailed + } + defer { + compression_stream_destroy(streamPtr) + streamPtr.deallocate() } - guard decompressedSize > 0 else { + let chunkSize = 65_536 // 64KB output chunks + var result = Data() + result.reserveCapacity(min(deflateData.count * 10, 50_000_000)) + + let outputBuffer = UnsafeMutablePointer.allocate(capacity: chunkSize) + defer { outputBuffer.deallocate() } + + try deflateData.withUnsafeBytes { sourceBuffer in + let sourcePtr = sourceBuffer.bindMemory(to: UInt8.self) + streamPtr.pointee.src_ptr = sourcePtr.baseAddress! + streamPtr.pointee.src_size = sourcePtr.count + + while true { + streamPtr.pointee.dst_ptr = outputBuffer + streamPtr.pointee.dst_size = chunkSize + + let processStatus = compression_stream_process(streamPtr, Int32(COMPRESSION_STREAM_FINALIZE.rawValue)) + + let bytesWritten = chunkSize - streamPtr.pointee.dst_size + if bytesWritten > 0 { + result.append(outputBuffer, count: bytesWritten) + } + + switch processStatus { + case COMPRESSION_STATUS_OK: + continue + case COMPRESSION_STATUS_END: + return + default: + throw ALSParserError.decompressionFailed + } + } + } + + guard !result.isEmpty else { throw ALSParserError.decompressionFailed } - return Data(destinationBuffer.prefix(decompressedSize)) + return result } private nonisolated func parseXML(_ xmlString: String) -> ParsedProjectData { @@ -186,8 +247,8 @@ struct ALSParser: Sendable { result.bpm = extractBPM(from: xmlString) // Parse time signature - result.timeSignatureNumerator = extractFirstInt(from: xmlString, pattern: #"[^<]*<[^>]*Numerator Value="(\d+)""#) ?? 4 - result.timeSignatureDenominator = extractFirstInt(from: xmlString, pattern: #"[^<]*<[^>]*Denominator Value="(\d+)""#) ?? 4 + result.timeSignatureNumerator = extractFirstInt(from: xmlString, regex: ALSRegex.timeSignatureNumerator) ?? 4 + result.timeSignatureDenominator = extractFirstInt(from: xmlString, regex: ALSRegex.timeSignatureDenominator) ?? 4 // Count tracks - fast counting without creating arrays result.audioTrackCount = countOccurrences(of: " 0 { result.duration = (beats / bpm) * 60.0 } @@ -242,10 +303,7 @@ struct ALSParser: Sendable { return Double(tempoBlock[manualStart.upperBound.. Double? { - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { - return nil - } + private nonisolated func extractFirstDouble(from string: String, regex: NSRegularExpression) -> Double? { let range = NSRange(string.startIndex..., in: string) guard let match = regex.firstMatch(in: string, options: [], range: range), match.numberOfRanges > 1, @@ -255,10 +313,7 @@ struct ALSParser: Sendable { return Double(string[valueRange]) } - private nonisolated func extractFirstInt(from string: String, pattern: String) -> Int? { - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { - return nil - } + private nonisolated func extractFirstInt(from string: String, regex: NSRegularExpression) -> Int? { let range = NSRange(string.startIndex..., in: string) guard let match = regex.firstMatch(in: string, options: [], range: range), match.numberOfRanges > 1, @@ -271,14 +326,8 @@ struct ALSParser: Sendable { private nonisolated func extractSampleNames(from xmlString: String) -> [String] { var names: Set = [] - // Only look for sample file names, not full paths - let pattern = #" [String] { var keys: Set = [] - // Pattern to match ScaleInformation blocks with Root and Name values - // Structure: - let pattern = #"\s*\s*= 3, diff --git a/abledex/Scanner/AudioPreviewService.swift b/abledex/Scanner/AudioPreviewService.swift index 0209a8a..5534496 100644 --- a/abledex/Scanner/AudioPreviewService.swift +++ b/abledex/Scanner/AudioPreviewService.swift @@ -139,9 +139,10 @@ final class AudioPreviewService { duration = audioPlayer?.duration ?? 0 playbackProgress = 0 - // Start progress timer + // Start progress timer — fires on main run loop, no Task wrapper needed progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in + // Timer is on main run loop; MainActor check is compile-time only + MainActor.assumeIsolated { self?.updateProgress() } } @@ -182,7 +183,7 @@ final class AudioPreviewService { audioPlayer?.play() isPlaying = true progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in + MainActor.assumeIsolated { self?.updateProgress() } } diff --git a/abledex/Services/DuplicateDetectionService.swift b/abledex/Services/DuplicateDetectionService.swift index 4e32a24..dadbb40 100644 --- a/abledex/Services/DuplicateDetectionService.swift +++ b/abledex/Services/DuplicateDetectionService.swift @@ -57,6 +57,16 @@ struct DuplicateDetectionService: Sendable { /// Find projects with similar characteristics private func findSimilarProjects(in projects: [ProjectRecord]) -> [DuplicateGroup] { + // Pre-decode all plugin sets once to avoid repeated JSON decoding + lock contention in O(n²) loop + var pluginSetsById: [UUID: Set] = [:] + pluginSetsById.reserveCapacity(projects.count) + for project in projects { + let plugins = project.plugins + if !plugins.isEmpty { + pluginSetsById[project.id] = Set(plugins) + } + } + var groups: [DuplicateGroup] = [] var processed = Set() @@ -70,7 +80,7 @@ struct DuplicateDetectionService: Sendable { guard other.id != project.id else { continue } guard !processed.contains(other.id) else { continue } - if isSimilar(project, other) { + if isSimilarFast(project, other, pluginsA: pluginSetsById[project.id], pluginsB: pluginSetsById[other.id]) { similar.append(other) processed.insert(other.id) } @@ -85,8 +95,8 @@ struct DuplicateDetectionService: Sendable { return groups } - /// Check if two projects are similar (BPM within 5, >50% plugin overlap) - private func isSimilar(_ a: ProjectRecord, _ b: ProjectRecord) -> Bool { + /// Fast similarity check using pre-decoded plugin sets + private func isSimilarFast(_ a: ProjectRecord, _ b: ProjectRecord, pluginsA: Set?, pluginsB: Set?) -> Bool { // Same hash means exact duplicate, handled separately if a.fileHash != nil && a.fileHash == b.fileHash { return false @@ -94,43 +104,15 @@ struct DuplicateDetectionService: Sendable { // Check BPM similarity (within 5 BPM) guard let bpmA = a.bpm, let bpmB = b.bpm else { return false } - let bpmDiff = abs(bpmA - bpmB) - guard bpmDiff <= 5 else { return false } + guard abs(bpmA - bpmB) <= 5 else { return false } - // Check plugin overlap (>50%) - let pluginsA = Set(a.plugins) - let pluginsB = Set(b.plugins) - - guard !pluginsA.isEmpty && !pluginsB.isEmpty else { return false } + // Check plugin overlap (>50%) using pre-decoded sets + guard let pluginsA = pluginsA, let pluginsB = pluginsB, + !pluginsA.isEmpty, !pluginsB.isEmpty else { return false } let overlap = pluginsA.intersection(pluginsB).count let minCount = min(pluginsA.count, pluginsB.count) - let overlapRatio = Double(overlap) / Double(minCount) - - return overlapRatio > 0.5 - } - - /// Get duplicate projects for a specific project - func duplicatesOf(_ project: ProjectRecord, in allProjects: [ProjectRecord]) -> [ProjectRecord] { - var duplicates: [ProjectRecord] = [] - - // Exact duplicates (same hash) - if let hash = project.fileHash { - duplicates.append(contentsOf: allProjects.filter { - $0.id != project.id && $0.fileHash == hash - }) - } - - // Similar projects - duplicates.append(contentsOf: allProjects.filter { - $0.id != project.id && isSimilar(project, $0) - }) - - return duplicates - } - /// Check if a project has any duplicates - func hasDuplicates(_ project: ProjectRecord, in allProjects: [ProjectRecord]) -> Bool { - !duplicatesOf(project, in: allProjects).isEmpty + return Double(overlap) / Double(minCount) > 0.5 } } diff --git a/abledex/Views/ProjectList/ProjectDetailView.swift b/abledex/Views/ProjectList/ProjectDetailView.swift index b1ff25d..ce2ab0f 100644 --- a/abledex/Views/ProjectList/ProjectDetailView.swift +++ b/abledex/Views/ProjectList/ProjectDetailView.swift @@ -879,13 +879,23 @@ struct XMLTextView: NSViewRepresentable { struct FlowLayout: Layout { var spacing: CGFloat = 8 - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + struct CachedLayout { + var size: CGSize + var frames: [CGRect] + } + + func makeCache(subviews: Subviews) -> CachedLayout? { + nil + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CachedLayout?) -> CGSize { let result = arrange(proposal: proposal, subviews: subviews) + cache = result return result.size } - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let result = arrange(proposal: proposal, subviews: subviews) + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CachedLayout?) { + let result = cache ?? arrange(proposal: proposal, subviews: subviews) for (index, frame) in result.frames.enumerated() { subviews[index].place( @@ -895,7 +905,7 @@ struct FlowLayout: Layout { } } - private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) { + private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> CachedLayout { let width = proposal.width ?? .infinity var frames: [CGRect] = [] var x: CGFloat = 0 @@ -917,6 +927,6 @@ struct FlowLayout: Layout { } let totalHeight = y + rowHeight - return (CGSize(width: width, height: totalHeight), frames) + return CachedLayout(size: CGSize(width: width, height: totalHeight), frames: frames) } } From 1939306f137ac5ca3e5a000186a54c3e2fca7a12 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Tue, 3 Feb 2026 15:09:57 -0800 Subject: [PATCH 4/4] theme implementation --- abledex/ContentView.swift | 5 +- abledex/Theme/AppTheme+Studio.swift | 84 ++++++++++++++++ abledex/Theme/AppTheme+System.swift | 80 +++++++++++++++ abledex/Theme/AppTheme.swift | 99 +++++++++++++++++++ abledex/Theme/ThemeEnvironment.swift | 75 ++++++++++++++ abledex/Theme/ThemeManager.swift | 33 +++++++ abledex/Theme/ThemedViewModifiers.swift | 88 +++++++++++++++++ .../ProjectDetail/AudioScrubberView.swift | 7 +- .../ProjectList/ProjectDetail/BadgeView.swift | 7 +- .../VersionTimelineSection.swift | 7 +- .../ProjectDetail/WaveformView.swift | 7 +- .../Views/ProjectList/ProjectDetailView.swift | 83 ++++++---------- .../Views/ProjectList/ProjectTableView.swift | 17 +--- .../Views/Settings/GeneralSettingsView.swift | 18 ++++ abledex/Views/Sidebar/SidebarView.swift | 47 +++------ abledex/Views/StatisticsView.swift | 36 +++---- abledex/abledexApp.swift | 10 ++ 17 files changed, 563 insertions(+), 140 deletions(-) create mode 100644 abledex/Theme/AppTheme+Studio.swift create mode 100644 abledex/Theme/AppTheme+System.swift create mode 100644 abledex/Theme/AppTheme.swift create mode 100644 abledex/Theme/ThemeEnvironment.swift create mode 100644 abledex/Theme/ThemeManager.swift create mode 100644 abledex/Theme/ThemedViewModifiers.swift diff --git a/abledex/ContentView.swift b/abledex/ContentView.swift index 90dfaee..a077eba 100644 --- a/abledex/ContentView.swift +++ b/abledex/ContentView.swift @@ -9,6 +9,7 @@ import SwiftUI struct ContentView: View { @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var showStatistics = false @@ -38,10 +39,10 @@ struct ContentView: View { } detail: { if let project = appState.selectedProject { ProjectDetailView(project: project) - .navigationSplitViewColumnWidth(min: 100, ideal: 180) + .navigationSplitViewColumnWidth(min: 300, ideal: 350) } else { ProjectDetailEmptyView() - .navigationSplitViewColumnWidth(min: 40, ideal: 160, max: 200) + .navigationSplitViewColumnWidth(min: 200, ideal: 250) } } .navigationSplitViewStyle(.balanced) diff --git a/abledex/Theme/AppTheme+Studio.swift b/abledex/Theme/AppTheme+Studio.swift new file mode 100644 index 0000000..e1875fe --- /dev/null +++ b/abledex/Theme/AppTheme+Studio.swift @@ -0,0 +1,84 @@ +// +// AppTheme+Studio.swift +// abledex +// +// Created by Brett Henderson on 1/30/26. +// + +import SwiftUI +import AppKit + +extension AppTheme { + static let studio: AppTheme = { + let abletonOrange = Color(red: 0.95, green: 0.55, blue: 0.0) + + return AppTheme( + // Surfaces + background: Color(white: 0.08), + surfacePrimary: Color(white: 0.12), + surfaceSecondary: Color(white: 0.15), + surfaceSelected: abletonOrange.opacity(0.2), + barBackground: Color(white: 0.10), + usesCustomBackground: true, + showsBorder: true, + + // Borders + border: Color(white: 0.35), + separator: Color(white: 0.25), + + // Text + textPrimary: Color(white: 0.92), + textSecondary: Color(white: 0.55), + textTertiary: Color(white: 0.35), + + // Accent + accent: abletonOrange, + accentSubtle: abletonOrange.opacity(0.1), + + // Status — brighter for contrast on dark + statusNone: Color(white: 0.55), + statusIdea: Color(red: 1.0, green: 0.85, blue: 0.2), + statusInProgress: Color(red: 0.3, green: 0.6, blue: 1.0), + statusMixing: Color(red: 0.7, green: 0.4, blue: 1.0), + statusDone: Color(red: 0.3, green: 0.9, blue: 0.4), + + // Components + badgeBackground: abletonOrange.opacity(0.15), + badgeForeground: abletonOrange, + cardBackground: Color(white: 0.12), + + // Waveform + waveformPlayed: abletonOrange, + waveformUnplayed: Color(white: 0.2), + waveformPlayhead: Color.white, + + // Scrubber + scrubberTrack: Color(white: 0.2), + scrubberFill: abletonOrange, + + // Charts + chartPrimary: abletonOrange, + chartSecondary: Color(red: 0.3, green: 0.9, blue: 0.4), + + // XML Viewer + xmlBackground: NSColor(white: 0.08, alpha: 1.0), + xmlForeground: NSColor(white: 0.85, alpha: 1.0), + + // Color labels — boosted saturation+brightness for dark background + colorLabelColors: [ + .none: .clear, + .red: Color(red: 1.0, green: 0.35, blue: 0.35), + .orange: Color(red: 1.0, green: 0.6, blue: 0.2), + .yellow: Color(red: 1.0, green: 0.9, blue: 0.3), + .green: Color(red: 0.3, green: 0.9, blue: 0.4), + .blue: Color(red: 0.35, green: 0.65, blue: 1.0), + .purple: Color(red: 0.7, green: 0.45, blue: 1.0), + .gray: Color(white: 0.55), + ], + + windowBackground: NSColor(white: 0.08, alpha: 1.0), + + preferredColorScheme: .dark + ) + }() +} diff --git a/abledex/Theme/AppTheme+System.swift b/abledex/Theme/AppTheme+System.swift new file mode 100644 index 0000000..54d2c26 --- /dev/null +++ b/abledex/Theme/AppTheme+System.swift @@ -0,0 +1,80 @@ +// +// AppTheme+System.swift +// abledex +// +// Created by Brett Henderson on 1/30/26. +// + +import SwiftUI +import AppKit + +extension AppTheme { + static let system = AppTheme( + // Surfaces + background: Color(nsColor: .windowBackgroundColor), + surfacePrimary: Color(nsColor: .quaternaryLabelColor), + surfaceSecondary: Color(nsColor: .quaternaryLabelColor), + surfaceSelected: Color.accentColor.opacity(0.2), + barBackground: Color(nsColor: .windowBackgroundColor), + usesCustomBackground: false, + showsBorder: false, + + // Borders + border: Color.clear, + separator: Color(nsColor: .separatorColor), + + // Text + textPrimary: Color.primary, + textSecondary: Color.secondary, + textTertiary: Color(nsColor: .tertiaryLabelColor), + + // Accent + accent: Color.accentColor, + accentSubtle: Color.accentColor.opacity(0.1), + + // Status + statusNone: Color.secondary, + statusIdea: Color.yellow, + statusInProgress: Color.blue, + statusMixing: Color.purple, + statusDone: Color.green, + + // Components + badgeBackground: Color.accentColor.opacity(0.1), + badgeForeground: Color.accentColor, + cardBackground: Color(nsColor: .quaternaryLabelColor), + + // Waveform + waveformPlayed: Color.accentColor, + waveformUnplayed: Color.gray.opacity(0.3), + waveformPlayhead: Color.accentColor, + + // Scrubber + scrubberTrack: Color.gray.opacity(0.3), + scrubberFill: Color.accentColor, + + // Charts + chartPrimary: Color.blue, + chartSecondary: Color.green, + + // XML Viewer + xmlBackground: NSColor.textBackgroundColor, + xmlForeground: NSColor.textColor, + + // Color labels — same as current behavior + colorLabelColors: [ + .none: .clear, + .red: .red, + .orange: .orange, + .yellow: .yellow, + .green: .green, + .blue: .blue, + .purple: .purple, + .gray: .gray, + ], + + windowBackground: nil, + + preferredColorScheme: nil + ) +} diff --git a/abledex/Theme/AppTheme.swift b/abledex/Theme/AppTheme.swift new file mode 100644 index 0000000..8c0e41a --- /dev/null +++ b/abledex/Theme/AppTheme.swift @@ -0,0 +1,99 @@ +// +// AppTheme.swift +// abledex +// +// Created by Brett Henderson on 1/30/26. +// + +import SwiftUI +import AppKit + +enum ThemeName: String, CaseIterable, Identifiable { + case system = "System" + case studio = "Studio" + + var id: String { rawValue } +} + +struct AppTheme: Sendable { + // MARK: - Surfaces + let background: Color + let surfacePrimary: Color + let surfaceSecondary: Color + let surfaceSelected: Color + let barBackground: Color + + /// When true, views should hide the default system scroll/list/table backgrounds + /// and apply theme.background instead. + let usesCustomBackground: Bool + + /// When true, themed components draw visible borders + let showsBorder: Bool + + // MARK: - Borders + let border: Color + let separator: Color + + // MARK: - Text + let textPrimary: Color + let textSecondary: Color + let textTertiary: Color + + // MARK: - Accent + let accent: Color + let accentSubtle: Color + + // MARK: - Status + let statusNone: Color + let statusIdea: Color + let statusInProgress: Color + let statusMixing: Color + let statusDone: Color + + // MARK: - Components + let badgeBackground: Color + let badgeForeground: Color + let cardBackground: Color + + // MARK: - Waveform + let waveformPlayed: Color + let waveformUnplayed: Color + let waveformPlayhead: Color + + // MARK: - Scrubber + let scrubberTrack: Color + let scrubberFill: Color + + // MARK: - Charts + let chartPrimary: Color + let chartSecondary: Color + + // MARK: - XML Viewer (NSColor for AppKit) + let xmlBackground: NSColor + let xmlForeground: NSColor + + // MARK: - Color Labels + let colorLabelColors: [ColorLabel: Color] + + // MARK: - Window (NSColor for AppKit-level background) + let windowBackground: NSColor? + + // MARK: - Preferred color scheme + let preferredColorScheme: ColorScheme? + + // MARK: - Convenience + + func statusColor(for status: CompletionStatus) -> Color { + switch status { + case .none: return statusNone + case .idea: return statusIdea + case .inProgress: return statusInProgress + case .mixing: return statusMixing + case .done: return statusDone + } + } + + func colorLabel(for label: ColorLabel) -> Color { + colorLabelColors[label] ?? .clear + } +} diff --git a/abledex/Theme/ThemeEnvironment.swift b/abledex/Theme/ThemeEnvironment.swift new file mode 100644 index 0000000..4ede45a --- /dev/null +++ b/abledex/Theme/ThemeEnvironment.swift @@ -0,0 +1,75 @@ +// +// ThemeEnvironment.swift +// abledex +// +// Created by Brett Henderson on 1/30/26. +// + +import SwiftUI +import AppKit + +private struct ThemeKey: EnvironmentKey { + static let defaultValue: AppTheme = .system +} + +extension EnvironmentValues { + var theme: AppTheme { + get { self[ThemeKey.self] } + set { self[ThemeKey.self] = newValue } + } +} + +// MARK: - Window Background Modifier + +/// Sets the NSWindow backgroundColor directly so the custom theme background +/// fills the entire window, including behind NavigationSplitView column chrome. +struct WindowBackgroundModifier: ViewModifier { + let color: NSColor? + + func body(content: Content) -> some View { + content + .background(WindowBackgroundSetter(color: color)) + } +} + +private struct WindowBackgroundSetter: NSViewRepresentable { + let color: NSColor? + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let window = nsView.window else { return } + if let color { + window.backgroundColor = color + setVibrancy(active: false, in: window.contentView) + } else { + window.backgroundColor = .windowBackgroundColor + setVibrancy(active: true, in: window.contentView) + } + } + } + + /// Recursively enable/disable NSVisualEffectViews that create the sidebar + /// and content area translucency. + private func setVibrancy(active: Bool, in view: NSView?) { + guard let view else { return } + if let effectView = view as? NSVisualEffectView { + effectView.state = active ? .followsWindowActiveState : .inactive + effectView.material = active ? .sidebar : .windowBackground + } + for subview in view.subviews { + setVibrancy(active: active, in: subview) + } + } +} + +extension View { + func windowBackground(_ color: NSColor?) -> some View { + modifier(WindowBackgroundModifier(color: color)) + } +} diff --git a/abledex/Theme/ThemeManager.swift b/abledex/Theme/ThemeManager.swift new file mode 100644 index 0000000..4aae4f1 --- /dev/null +++ b/abledex/Theme/ThemeManager.swift @@ -0,0 +1,33 @@ +// +// ThemeManager.swift +// abledex +// +// Created by Brett Henderson on 1/30/26. +// + +import SwiftUI + +@MainActor @Observable +final class ThemeManager { + var selectedThemeName: ThemeName { + didSet { + UserDefaults.standard.set(selectedThemeName.rawValue, forKey: "selectedTheme") + } + } + + var current: AppTheme { + switch selectedThemeName { + case .system: return .system + case .studio: return .studio + } + } + + init() { + if let stored = UserDefaults.standard.string(forKey: "selectedTheme"), + let name = ThemeName(rawValue: stored) { + self.selectedThemeName = name + } else { + self.selectedThemeName = .system + } + } +} diff --git a/abledex/Theme/ThemedViewModifiers.swift b/abledex/Theme/ThemedViewModifiers.swift new file mode 100644 index 0000000..1b4702c --- /dev/null +++ b/abledex/Theme/ThemedViewModifiers.swift @@ -0,0 +1,88 @@ +// +// ThemedViewModifiers.swift +// abledex +// +// Created by Brett Henderson on 1/30/26. +// + +import SwiftUI + +// MARK: - Badge Style + +enum BadgeStyle { + case accent + case neutral + case tinted(Color) +} + +struct ThemedBadgeModifier: ViewModifier { + @Environment(\.theme) private var theme + let style: BadgeStyle + + private var backgroundColor: Color { + switch style { + case .accent: + return theme.badgeBackground + case .neutral: + return theme.surfacePrimary + case .tinted(let color): + return color.opacity(0.15) + } + } + + private var foregroundColor: Color { + switch style { + case .accent: + return theme.badgeForeground + case .neutral: + return theme.textSecondary + case .tinted(let color): + return color + } + } + + func body(content: Content) -> some View { + content + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(backgroundColor) + .foregroundStyle(foregroundColor) + .clipShape(Capsule()) + .overlay( + theme.showsBorder + ? Capsule().stroke(theme.border, lineWidth: 0.5) + : nil + ) + } +} + +// MARK: - Card Style + +struct ThemedCardModifier: ViewModifier { + @Environment(\.theme) private var theme + let cornerRadius: CGFloat + + func body(content: Content) -> some View { + content + .background(theme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .overlay( + theme.showsBorder + ? RoundedRectangle(cornerRadius: cornerRadius).stroke(theme.border, lineWidth: 0.5) + : nil + ) + } +} + +// MARK: - View Extensions + +extension View { + func themedBadge(_ style: BadgeStyle = .accent) -> some View { + modifier(ThemedBadgeModifier(style: style)) + } + + func themedCard(cornerRadius: CGFloat = 12) -> some View { + modifier(ThemedCardModifier(cornerRadius: cornerRadius)) + } +} diff --git a/abledex/Views/ProjectList/ProjectDetail/AudioScrubberView.swift b/abledex/Views/ProjectList/ProjectDetail/AudioScrubberView.swift index 3e20d34..cf1f4ed 100644 --- a/abledex/Views/ProjectList/ProjectDetail/AudioScrubberView.swift +++ b/abledex/Views/ProjectList/ProjectDetail/AudioScrubberView.swift @@ -13,6 +13,7 @@ struct AudioScrubberView: View { let isActive: Bool let onSeek: (Double) -> Void + @Environment(\.theme) private var theme @State private var isDragging = false @State private var dragProgress: Double = 0 @@ -30,18 +31,18 @@ struct AudioScrubberView: View { ZStack(alignment: .leading) { // Track background RoundedRectangle(cornerRadius: 2) - .fill(Color.gray.opacity(0.3)) + .fill(theme.scrubberTrack) .frame(height: 4) // Progress fill RoundedRectangle(cornerRadius: 2) - .fill(isActive ? Color.accentColor : Color.gray.opacity(0.5)) + .fill(isActive ? theme.scrubberFill : Color.gray.opacity(0.5)) .frame(width: max(0, geometry.size.width * progressFraction), height: 4) // Thumb (only show when active or dragging) if isActive || isDragging { Circle() - .fill(Color.accentColor) + .fill(theme.scrubberFill) .frame(width: 10, height: 10) .offset(x: max(0, min(geometry.size.width - 10, geometry.size.width * progressFraction - 5))) } diff --git a/abledex/Views/ProjectList/ProjectDetail/BadgeView.swift b/abledex/Views/ProjectList/ProjectDetail/BadgeView.swift index 67f57f1..17c8a95 100644 --- a/abledex/Views/ProjectList/ProjectDetail/BadgeView.swift +++ b/abledex/Views/ProjectList/ProjectDetail/BadgeView.swift @@ -13,12 +13,7 @@ struct BadgeView: View { var body: some View { Label(label, systemImage: icon) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.tint.opacity(0.1)) - .foregroundStyle(.tint) - .clipShape(Capsule()) + .themedBadge(.accent) } } diff --git a/abledex/Views/ProjectList/ProjectDetail/VersionTimelineSection.swift b/abledex/Views/ProjectList/ProjectDetail/VersionTimelineSection.swift index b5eb875..6dadb62 100644 --- a/abledex/Views/ProjectList/ProjectDetail/VersionTimelineSection.swift +++ b/abledex/Views/ProjectList/ProjectDetail/VersionTimelineSection.swift @@ -10,6 +10,7 @@ import SwiftUI struct VersionTimelineSection: View { let project: ProjectRecord @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme @State private var isExpanded = true private var versions: [ProjectRecord] { @@ -45,7 +46,7 @@ struct VersionTimelineSection: View { // Timeline connector VStack(spacing: 0) { Circle() - .fill(isCurrent ? Color.accentColor : Color.secondary.opacity(0.5)) + .fill(isCurrent ? theme.accent : Color.secondary.opacity(0.5)) .frame(width: 10, height: 10) if !isLast { @@ -71,8 +72,8 @@ struct VersionTimelineSection: View { .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(Color.accentColor.opacity(0.2)) - .foregroundStyle(Color.accentColor) + .background(theme.accent.opacity(0.2)) + .foregroundStyle(theme.accent) .clipShape(Capsule()) } diff --git a/abledex/Views/ProjectList/ProjectDetail/WaveformView.swift b/abledex/Views/ProjectList/ProjectDetail/WaveformView.swift index bf106e0..4d3e1a1 100644 --- a/abledex/Views/ProjectList/ProjectDetail/WaveformView.swift +++ b/abledex/Views/ProjectList/ProjectDetail/WaveformView.swift @@ -15,6 +15,7 @@ struct WaveformView: View { let isActive: Bool let onSeek: ((Double) -> Void)? + @Environment(\.theme) private var theme @State private var isDragging = false @State private var dragProgress: Double = 0 @@ -82,9 +83,9 @@ struct WaveformView: View { let color: Color if index < progressIndex { - color = isActive ? .accentColor : .gray.opacity(0.6) + color = isActive ? theme.waveformPlayed : .gray.opacity(0.6) } else { - color = .gray.opacity(0.3) + color = theme.waveformUnplayed } context.fill( @@ -100,7 +101,7 @@ struct WaveformView: View { var playheadPath = Path() playheadPath.move(to: CGPoint(x: playheadX, y: 0)) playheadPath.addLine(to: CGPoint(x: playheadX, y: size.height)) - context.stroke(playheadPath, with: .color(.accentColor), lineWidth: 2) + context.stroke(playheadPath, with: .color(theme.waveformPlayhead), lineWidth: 2) } } } diff --git a/abledex/Views/ProjectList/ProjectDetailView.swift b/abledex/Views/ProjectList/ProjectDetailView.swift index ce2ab0f..490bbed 100644 --- a/abledex/Views/ProjectList/ProjectDetailView.swift +++ b/abledex/Views/ProjectList/ProjectDetailView.swift @@ -11,6 +11,7 @@ import AppKit struct ProjectDetailView: View { let project: ProjectRecord @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme @AppStorage("useCamelotNotation") private var useCamelotNotation = false @State private var editingNotes: String = "" @State private var newTag: String = "" @@ -206,7 +207,7 @@ struct ProjectDetailView: View { @ViewBuilder private func statusButton(for status: CompletionStatus) -> some View { let isSelected = project.completionStatus == status - let color = statusColor(status) + let color = theme.statusColor(for: status) Button { Task { @@ -222,15 +223,18 @@ struct ProjectDetailView: View { .minimumScaleFactor(0.8) } .frame(minWidth: 56, maxWidth: .infinity, minHeight: 48) - .background(isSelected ? color.opacity(0.2) : Color.gray.opacity(0.15)) + .background(isSelected ? color.opacity(0.2) : theme.surfacePrimary) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? color : Color.clear, lineWidth: 2) + .stroke( + isSelected ? color : (theme.showsBorder ? theme.border : .clear), + lineWidth: isSelected ? 2 : 0.5 + ) ) } .buttonStyle(.plain) - .foregroundStyle(isSelected ? color : Color.secondary) + .foregroundStyle(isSelected ? color : theme.textSecondary) } private var tagSuggestions: [String] { @@ -258,12 +262,7 @@ struct ProjectDetailView: View { } .buttonStyle(.plain) } - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.tint.opacity(0.15)) - .foregroundStyle(.tint) - .clipShape(Capsule()) + .themedBadge(.accent) } // Add tag field @@ -306,7 +305,7 @@ struct ProjectDetailView: View { .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(.quaternary) + .background(theme.surfacePrimary) .clipShape(Capsule()) } } @@ -345,16 +344,6 @@ struct ProjectDetailView: View { } } - private func statusColor(_ status: CompletionStatus) -> Color { - switch status { - case .none: return .secondary - case .idea: return .yellow - case .inProgress: return .blue - case .mixing: return .purple - case .done: return .green - } - } - private var colorLabelSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Color") @@ -371,7 +360,7 @@ struct ProjectDetailView: View { @ViewBuilder private func colorLabelButton(for label: ColorLabel) -> some View { let isSelected = project.colorLabel == label - let color = colorForLabel(label) + let color = theme.colorLabel(for: label) Button { Task { @@ -381,7 +370,7 @@ struct ProjectDetailView: View { if label == .none { Image(systemName: isSelected ? "circle.slash" : "circle.slash") .font(.title2) - .foregroundStyle(isSelected ? .primary : .tertiary) + .foregroundStyle(isSelected ? theme.textPrimary : theme.textTertiary) } else { Image(systemName: isSelected ? "circle.fill" : "circle") .font(.title2) @@ -390,23 +379,10 @@ struct ProjectDetailView: View { } .buttonStyle(.plain) .padding(4) - .background(isSelected ? Color.gray.opacity(0.2) : Color.clear) + .background(isSelected ? theme.surfacePrimary : Color.clear) .clipShape(Circle()) } - private func colorForLabel(_ label: ColorLabel) -> Color { - switch label { - case .none: return .clear - case .red: return .red - case .orange: return .orange - case .yellow: return .yellow - case .green: return .green - case .blue: return .blue - case .purple: return .purple - case .gray: return .gray - } - } - private var detailsSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Details") @@ -481,11 +457,7 @@ struct ProjectDetailView: View { FlowLayout(spacing: 6) { ForEach(project.plugins, id: \.self) { plugin in Text(plugin) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.quaternary) - .clipShape(Capsule()) + .themedBadge(.neutral) } } } @@ -503,12 +475,7 @@ struct ProjectDetailView: View { .font(.caption2) Text(displayKey(key)) } - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.pink.opacity(0.15)) - .foregroundStyle(.pink) - .clipShape(Capsule()) + .themedBadge(.tinted(.pink)) } } } @@ -636,7 +603,7 @@ struct ProjectDetailView: View { } .padding(.vertical, 6) .padding(.horizontal, 8) - .background(isCurrentlyPlaying ? Color.accentColor.opacity(0.1) : Color.clear) + .background(isCurrentlyPlaying ? theme.accentSubtle : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 6)) } @@ -709,7 +676,7 @@ struct ProjectDetailView: View { .font(.callout) .frame(minHeight: 100) .padding(8) - .background(.quaternary) + .background(theme.surfaceSecondary) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { Text(project.userNotes?.isEmpty == false ? project.userNotes! : "No notes added") @@ -717,7 +684,7 @@ struct ProjectDetailView: View { .foregroundStyle(project.userNotes?.isEmpty == false ? .primary : .tertiary) .frame(maxWidth: .infinity, alignment: .leading) .padding() - .background(.quaternary) + .background(theme.surfaceSecondary) .clipShape(RoundedRectangle(cornerRadius: 8)) } } @@ -736,6 +703,7 @@ struct ProjectDetailView: View { struct XMLViewerSheet: View { let project: ProjectRecord @Environment(\.dismiss) private var dismiss + @Environment(\.theme) private var theme @State private var xmlContent: String = "" @State private var isLoading: Bool = true @State private var errorMessage: String? @@ -787,10 +755,11 @@ struct XMLViewerSheet: View { } Spacer() } else { - XMLTextView(text: xmlContent) + XMLTextView(text: xmlContent, backgroundColor: theme.xmlBackground, foregroundColor: theme.xmlForeground) } } .frame(minWidth: 700, minHeight: 500) + .background(theme.usesCustomBackground ? theme.background.ignoresSafeArea() : nil) .task { await loadXML() } @@ -821,6 +790,8 @@ struct XMLViewerSheet: View { /// Uses Coordinator to set text off the main layout pass to avoid UI stalls. struct XMLTextView: NSViewRepresentable { let text: String + var backgroundColor: NSColor = .textBackgroundColor + var foregroundColor: NSColor = .textColor func makeCoordinator() -> Coordinator { Coordinator() @@ -833,7 +804,7 @@ struct XMLTextView: NSViewRepresentable { textView.isEditable = false textView.isSelectable = true textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) - textView.backgroundColor = NSColor.textBackgroundColor + textView.backgroundColor = backgroundColor textView.textContainerInset = NSSize(width: 8, height: 8) textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false @@ -854,17 +825,19 @@ struct XMLTextView: NSViewRepresentable { } func updateNSView(_ scrollView: NSScrollView, context: Context) { + let textView = context.coordinator.textView! + textView.backgroundColor = backgroundColor + guard context.coordinator.currentText != text else { return } context.coordinator.currentText = text - let textView = context.coordinator.textView! // Set text content with layout temporarily disabled to avoid stalling textView.textStorage?.beginEditing() textView.textStorage?.setAttributedString(NSAttributedString( string: text, attributes: [ .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), - .foregroundColor: NSColor.textColor + .foregroundColor: foregroundColor ] )) textView.textStorage?.endEditing() diff --git a/abledex/Views/ProjectList/ProjectTableView.swift b/abledex/Views/ProjectList/ProjectTableView.swift index 69fa316..8748326 100644 --- a/abledex/Views/ProjectList/ProjectTableView.swift +++ b/abledex/Views/ProjectList/ProjectTableView.swift @@ -9,6 +9,7 @@ import SwiftUI struct ProjectTableView: View { @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme @State private var showDeleteConfirmation = false @State private var showBatchTagSheet = false @State private var batchTagInput = "" @@ -57,7 +58,7 @@ struct ProjectTableView: View { TableColumn("Status") { project in HStack(spacing: 4) { Image(systemName: project.completionStatus.icon) - .foregroundStyle(statusColor(project.completionStatus)) + .foregroundStyle(theme.statusColor(for: project.completionStatus)) Text(project.completionStatus.label) .font(.caption) } @@ -158,7 +159,8 @@ struct ProjectTableView: View { } } } - .tableStyle(.inset(alternatesRowBackgrounds: true)) + .tableStyle(.inset(alternatesRowBackgrounds: !theme.usesCustomBackground)) + .scrollContentBackground(theme.usesCustomBackground ? .hidden : .automatic) .onDeleteCommand { if !appState.selectedProjectIDs.isEmpty { showDeleteConfirmation = true @@ -261,7 +263,7 @@ struct ProjectTableView: View { } .padding(.horizontal) .padding(.vertical, 8) - .background(.bar) + .background(theme.usesCustomBackground ? AnyShapeStyle(theme.barBackground) : AnyShapeStyle(.bar)) } // MARK: - Batch Tag Sheet @@ -403,15 +405,6 @@ struct ProjectTableView: View { } } - private func statusColor(_ status: CompletionStatus) -> Color { - switch status { - case .none: return .secondary - case .idea: return .yellow - case .inProgress: return .blue - case .mixing: return .purple - case .done: return .green - } - } } diff --git a/abledex/Views/Settings/GeneralSettingsView.swift b/abledex/Views/Settings/GeneralSettingsView.swift index c98da5e..1a2de0c 100644 --- a/abledex/Views/Settings/GeneralSettingsView.swift +++ b/abledex/Views/Settings/GeneralSettingsView.swift @@ -12,9 +12,27 @@ struct GeneralSettingsView: View { @AppStorage("scanExternalVolumes") private var scanExternalVolumes = true @AppStorage("showMissingSamplesWarning") private var showMissingSamplesWarning = true @AppStorage("useCamelotNotation") private var useCamelotNotation = false + @Environment(ThemeManager.self) private var themeManager var body: some View { + @Bindable var tm = themeManager + Form { + Section { + Picker("Theme", selection: $tm.selectedThemeName) { + ForEach(ThemeName.allCases) { name in + Text(name.rawValue).tag(name) + } + } + .pickerStyle(.segmented) + + Text("System uses macOS colors. Studio is a high-contrast dark theme with Ableton orange accent.") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Appearance") + } + Section { Toggle("Scan on launch", isOn: $autoScanOnLaunch) Toggle("Include external volumes", isOn: $scanExternalVolumes) diff --git a/abledex/Views/Sidebar/SidebarView.swift b/abledex/Views/Sidebar/SidebarView.swift index 51edb1f..67cf865 100644 --- a/abledex/Views/Sidebar/SidebarView.swift +++ b/abledex/Views/Sidebar/SidebarView.swift @@ -9,6 +9,7 @@ import SwiftUI struct SidebarView: View { @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme @AppStorage("useCamelotNotation") private var useCamelotNotation = false @State private var isLibraryExpanded = true @State private var expandedSections: Set = [.status, .tags, .colors, .locations] @@ -39,6 +40,7 @@ struct SidebarView: View { } } .listStyle(.sidebar) + .scrollContentBackground(theme.usesCustomBackground ? .hidden : .automatic) .safeAreaInset(edge: .bottom) { bottomBar } @@ -90,7 +92,7 @@ struct SidebarView: View { .buttonStyle(.plain) .listRowBackground( appState.showDuplicatesOnly - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -146,13 +148,13 @@ struct SidebarView: View { } } icon: { Image(systemName: status.icon) - .foregroundStyle(statusColor(for: status)) + .foregroundStyle(theme.statusColor(for: status)) } } .buttonStyle(.plain) .listRowBackground( appState.selectedStatusFilter == status - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -186,13 +188,13 @@ struct SidebarView: View { } } icon: { Image(systemName: "circle.fill") - .foregroundStyle(colorForLabel(label)) + .foregroundStyle(theme.colorLabel(for: label)) } } .buttonStyle(.plain) .listRowBackground( appState.selectedColorLabelFilter == label - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -233,7 +235,7 @@ struct SidebarView: View { .buttonStyle(.plain) .listRowBackground( appState.selectedPluginFilter == plugin - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -279,7 +281,7 @@ struct SidebarView: View { .buttonStyle(.plain) .listRowBackground( appState.selectedKeyFilter == key - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -320,7 +322,7 @@ struct SidebarView: View { .buttonStyle(.plain) .listRowBackground( appState.selectedFolderFilter == folder - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -365,7 +367,7 @@ struct SidebarView: View { .buttonStyle(.plain) .listRowBackground( appState.selectedTagFilter == tag - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -404,7 +406,7 @@ struct SidebarView: View { .buttonStyle(.plain) .listRowBackground( appState.selectedVolumeFilter == volume - ? Color.accentColor.opacity(0.2) + ? theme.surfaceSelected : Color.clear ) } @@ -511,7 +513,7 @@ struct SidebarView: View { .padding(.horizontal) } .padding(.vertical, 8) - .background(.bar) + .background(theme.usesCustomBackground ? AnyShapeStyle(theme.barBackground) : AnyShapeStyle(.bar)) } // MARK: - Expansion Binding Helper @@ -700,29 +702,6 @@ struct SidebarView: View { appState.statusCounts[status] ?? 0 } - private func statusColor(for status: CompletionStatus) -> Color { - switch status { - case .none: return .secondary - case .idea: return .yellow - case .inProgress: return .blue - case .mixing: return .purple - case .done: return .green - } - } - - private func colorForLabel(_ label: ColorLabel) -> Color { - switch label { - case .none: return .clear - case .red: return .red - case .orange: return .orange - case .yellow: return .yellow - case .green: return .green - case .blue: return .blue - case .purple: return .purple - case .gray: return .gray - } - } - private func selectFolder() { let panel = NSOpenPanel() panel.allowsMultipleSelection = false diff --git a/abledex/Views/StatisticsView.swift b/abledex/Views/StatisticsView.swift index 6d494a9..dad2ba6 100644 --- a/abledex/Views/StatisticsView.swift +++ b/abledex/Views/StatisticsView.swift @@ -11,6 +11,7 @@ import Charts struct StatisticsView: View { @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + @Environment(\.theme) private var theme // Cached storage data to avoid synchronous file I/O on every render @State private var cachedStorageByVolume: [(volume: String, size: Int64, count: Int)] = [] @@ -200,8 +201,7 @@ struct StatisticsView: View { } .padding(.vertical, 4) .padding(.horizontal, 8) - .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .themedCard(cornerRadius: 6) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -223,7 +223,7 @@ struct StatisticsView: View { x: .value("BPM Range", item.range), y: .value("Count", item.count) ) - .foregroundStyle(.blue.gradient) + .foregroundStyle(theme.chartPrimary.gradient) } .frame(height: 200) } else { @@ -233,7 +233,7 @@ struct StatisticsView: View { Text("\(item.count)") .font(.caption2) Rectangle() - .fill(.blue) + .fill(theme.chartPrimary) .frame(width: 40, height: CGFloat(item.count) * 5) Text(item.range) .font(.caption2) @@ -265,13 +265,13 @@ struct StatisticsView: View { x: .value("Week", item.week, unit: .weekOfYear), y: .value("Count", item.count) ) - .foregroundStyle(.green.opacity(0.3)) + .foregroundStyle(theme.chartSecondary.opacity(0.3)) LineMark( x: .value("Week", item.week, unit: .weekOfYear), y: .value("Count", item.count) ) - .foregroundStyle(.green) + .foregroundStyle(theme.chartSecondary) } .frame(height: 150) } @@ -292,7 +292,7 @@ struct StatisticsView: View { let maxCount = Double(projectsByDayOfWeek.map(\.count).max() ?? 1) let width = maxCount > 0 ? (Double(item.count) / maxCount) * geometry.size.width : 0 RoundedRectangle(cornerRadius: 3) - .fill(.green.gradient) + .fill(theme.chartSecondary.gradient) .frame(width: max(0, width), height: 16) } .frame(height: 16) @@ -362,7 +362,7 @@ struct StatisticsView: View { x: .value("Month", item.month, unit: .month), y: .value("Count", item.count) ) - .foregroundStyle(.blue.gradient) + .foregroundStyle(theme.chartPrimary.gradient) } .frame(height: 200) } @@ -371,6 +371,7 @@ struct StatisticsView: View { .padding() } .frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 700) + .background(theme.usesCustomBackground ? theme.background.ignoresSafeArea() : nil) .task { await loadStorageData() } @@ -434,16 +435,7 @@ struct StatisticsView: View { private var statusData: [(status: CompletionStatus, count: Int, color: Color)] { CompletionStatus.allCases.map { status in let count = appState.projects.filter { $0.completionStatus == status }.count - let color: Color = { - switch status { - case .none: return .gray - case .idea: return .yellow - case .inProgress: return .blue - case .mixing: return .purple - case .done: return .green - } - }() - return (status, count, color) + return (status, count, theme.statusColor(for: status)) } } @@ -572,6 +564,7 @@ struct StatCard: View { let icon: String let color: Color var action: (() -> Void)? = nil + @Environment(\.theme) private var theme var body: some View { Group { @@ -602,8 +595,7 @@ struct StatCard: View { .foregroundStyle(.secondary) } .padding() - .background(.quaternary) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .themedCard(cornerRadius: 12) .contentShape(Rectangle()) } } @@ -614,6 +606,7 @@ struct StatMiniCard: View { let title: String let value: String let icon: String + @Environment(\.theme) private var theme var body: some View { HStack { @@ -628,7 +621,6 @@ struct StatMiniCard: View { } } .padding(8) - .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .themedCard(cornerRadius: 8) } } diff --git a/abledex/abledexApp.swift b/abledex/abledexApp.swift index 669a2bb..3891f61 100644 --- a/abledex/abledexApp.swift +++ b/abledex/abledexApp.swift @@ -10,6 +10,7 @@ import SwiftUI @main struct AbledexApp: App { @State private var appState: AppState + @State private var themeManager = ThemeManager() init() { do { @@ -24,6 +25,11 @@ struct AbledexApp: App { WindowGroup { ContentView() .environment(appState) + .environment(themeManager) + .environment(\.theme, themeManager.current) + .preferredColorScheme(themeManager.current.preferredColorScheme) + .tint(themeManager.current.usesCustomBackground ? themeManager.current.accent : nil) + .windowBackground(themeManager.current.windowBackground) .task { await appState.loadData() appState.startVolumeMonitoring() @@ -75,6 +81,10 @@ struct AbledexApp: App { Settings { SettingsView() .environment(appState) + .environment(themeManager) + .environment(\.theme, themeManager.current) + .preferredColorScheme(themeManager.current.preferredColorScheme) + .tint(themeManager.current.usesCustomBackground ? themeManager.current.accent : nil) } #endif }