From 6d2ee057865c79be3c278c1ba52bb31652c1a79d Mon Sep 17 00:00:00 2001 From: John Burwell Date: Mon, 25 May 2026 16:57:49 -0500 Subject: [PATCH] UI and behavior changes --- .../project.pbxproj | 2 + .../AccentColor.colorset/Contents.json | 9 ++ World Manager for Minecraft/ContentView.swift | 145 ++++++++++++----- .../Models/MinecraftContentItem.swift | 3 + .../Services/ContentPackageExporter.swift | 24 ++- .../Services/SourceLibrary.swift | 151 ++++++++++++------ .../Services/WorldScanner.swift | 50 ++++-- .../World_Manager_for_MinecraftApp.swift | 2 + 8 files changed, 277 insertions(+), 109 deletions(-) diff --git a/World Manager for Minecraft.xcodeproj/project.pbxproj b/World Manager for Minecraft.xcodeproj/project.pbxproj index 81656d2..66a406a 100644 --- a/World Manager for Minecraft.xcodeproj/project.pbxproj +++ b/World Manager for Minecraft.xcodeproj/project.pbxproj @@ -399,6 +399,7 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -429,6 +430,7 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/World Manager for Minecraft/Assets.xcassets/AccentColor.colorset/Contents.json b/World Manager for Minecraft/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..8ec5091 100644 --- a/World Manager for Minecraft/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/World Manager for Minecraft/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,15 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.240", + "green" : "0.630", + "red" : "0.360" + } + }, "idiom" : "universal" } ], diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index c1f251b..166edb9 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -22,13 +22,31 @@ struct ContentView: View { var body: some View { NavigationSplitView { VStack(spacing: 0) { + SidebarSourcesHeaderView(addSourceAction: pickFolder) + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + List(selection: $selectedSidebarSelection) { ForEach(library.sources) { source in - Section(source.displayName) { - ForEach(sidebarFilters(for: source)) { filter in - SidebarFilterRow(filter: filter) - .tag(filter.selection as SidebarSelection?) + SourceHeaderRow(title: source.displayName) + .listRowSeparator(.hidden) + .padding(.top, 6) + .contextMenu { + Button("Rescan \"\(source.displayName)\"") { + library.rescanSource(withID: source.id) + } + + Divider() + + Button("Remove \"\(source.displayName)\"", role: .destructive) { + removeSource(source.id) + } } + + ForEach(sidebarFilters(for: source)) { filter in + SidebarFilterRow(filter: filter, isIndented: true) + .tag(filter.selection as SidebarSelection?) } } } @@ -94,7 +112,6 @@ struct ContentView: View { .foregroundStyle(.secondary) } } - .tint(.minecraftAccent) .searchable(text: $searchText, placement: .toolbar, prompt: searchPrompt) .toolbar { ToolbarItemGroup(placement: .primaryAction) { @@ -122,29 +139,6 @@ struct ContentView: View { } .disabled(isPerformingItemAction) } - - Button { - pickFolder() - } label: { - Label("Add Source", systemImage: "plus") - } - - if let currentSource = currentSource { - Menu { - Button("Rescan \"\(currentSource.displayName)\"") { - library.rescanSource(withID: currentSource.id) - } - - Divider() - - Button("Remove \"\(currentSource.displayName)\"", role: .destructive) { - removeSource(currentSource.id) - } - } label: { - Image(systemName: "ellipsis.circle") - } - .help("Source actions") - } } } .onChange(of: filteredItems.map(\.id)) { _, filteredIDs in @@ -625,6 +619,7 @@ private struct SidebarFilter: Identifiable, Hashable { private struct SidebarFilterRow: View { let filter: SidebarFilter + let isIndented: Bool var body: some View { HStack(spacing: 10) { @@ -639,6 +634,37 @@ private struct SidebarFilterRow: View { Text(filter.count, format: .number) .foregroundStyle(.secondary) } + .padding(.leading, isIndented ? 16 : 0) + } +} + +private struct SidebarSourcesHeaderView: View { + let addSourceAction: () -> Void + + var body: some View { + HStack { + Text("Sources") + .font(.headline) + .foregroundStyle(.secondary) + + Spacer() + + Button(action: addSourceAction) { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + .help("Add Source") + } + } +} + +private struct SourceHeaderRow: View { + let title: String + + var body: some View { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) } } @@ -688,7 +714,7 @@ private struct SidebarFooterView: View { case .failure: return .red case .success: - return .minecraftAccent + return .appAccent } } } @@ -765,6 +791,7 @@ private struct ItemDetailView: View { let primaryAction: () -> Void let shareAction: (NSView) -> Void let revealAction: () -> Void + @State private var isTechnicalDetailsExpanded = false var body: some View { ScrollView { @@ -851,7 +878,7 @@ private struct ItemDetailView: View { } detailCard { - DisclosureGroup("Technical Details") { + DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) { VStack(alignment: .leading, spacing: 18) { detailRow(title: "Folder ID", value: item.folderID) detailRow(title: "Folder Path", value: item.folderURL.path) @@ -885,6 +912,15 @@ private struct ItemDetailView: View { } } .padding(.top, 8) + } label: { + HStack { + Text("Technical Details") + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + isTechnicalDetailsExpanded.toggle() + } } } } @@ -908,13 +944,17 @@ private struct ItemDetailView: View { .font(.subheadline.weight(.semibold)) ForEach(packs) { pack in - VStack(alignment: .leading, spacing: 2) { - Text(pack.name) + HStack(alignment: .top, spacing: 12) { + PackReferenceIconView(iconURL: pack.iconURL) - if let secondary = packSecondaryText(pack), !secondary.isEmpty { - Text(secondary) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(pack.name) + + if let secondary = packSecondaryText(pack), !secondary.isEmpty { + Text(secondary) + .font(.caption) + .foregroundStyle(.secondary) + } } } } @@ -1013,6 +1053,29 @@ private struct SharingPickerButton: NSViewRepresentable { } } +private struct PackReferenceIconView: View { + let iconURL: URL? + + var body: some View { + if let image = loadImage(from: iconURL) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 34, height: 34) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary) + .frame(width: 34, height: 34) + .overlay( + Image(systemName: "shippingbox") + .font(.caption) + .foregroundStyle(.secondary) + ) + } + } +} + private struct EmptySourcesView: View { let isDropTargeted: Bool let chooseFolder: () -> Void @@ -1022,12 +1085,12 @@ private struct EmptySourcesView: View { ZStack { RoundedRectangle(cornerRadius: 24) .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10])) - .foregroundStyle(isDropTargeted ? Color.minecraftAccent : Color.secondary.opacity(0.25)) + .foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary.opacity(0.25)) .frame(width: 220, height: 160) Image(systemName: "folder.badge.plus") .font(.system(size: 56, weight: .regular)) - .foregroundStyle(isDropTargeted ? Color.minecraftAccent : Color.secondary) + .foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary) } VStack(spacing: 8) { @@ -1080,8 +1143,8 @@ private struct LargeItemThumbnailView: View { if let image = loadImage(from: iconURL) { Image(nsImage: image) .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 420, minHeight: 260, maxHeight: 340) + .aspectRatio(image.size, contentMode: .fit) + .frame(maxWidth: 420, maxHeight: 340) .clipShape(RoundedRectangle(cornerRadius: 28)) } else { RoundedRectangle(cornerRadius: 28) @@ -1120,7 +1183,7 @@ private func loadImage(from url: URL?) -> NSImage? { } private extension Color { - static let minecraftAccent = Color(red: 0.36, green: 0.63, blue: 0.24) + static let appAccent = Color("AccentColor") } struct ContentView_Previews: PreviewProvider { diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift index c84675b..a68d258 100644 --- a/World Manager for Minecraft/Models/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -66,6 +66,7 @@ struct ContentPackReference: Identifiable, Hashable, Sendable { let id: String let name: String let type: MinecraftContentType + let iconURL: URL? let uuid: String? let version: String? let source: PackSource @@ -73,11 +74,13 @@ struct ContentPackReference: Identifiable, Hashable, Sendable { nonisolated init( name: String, type: MinecraftContentType, + iconURL: URL? = nil, uuid: String? = nil, version: String? = nil, source: PackSource ) { self.type = type + self.iconURL = iconURL self.uuid = uuid?.lowercased() self.version = version self.source = source diff --git a/World Manager for Minecraft/Services/ContentPackageExporter.swift b/World Manager for Minecraft/Services/ContentPackageExporter.swift index dd2472e..5092120 100644 --- a/World Manager for Minecraft/Services/ContentPackageExporter.swift +++ b/World Manager for Minecraft/Services/ContentPackageExporter.swift @@ -134,18 +134,36 @@ enum ContentPackageExporter { } nonisolated private static func sanitizedFilename(_ value: String) -> String { + let transliterated = portableASCIIString(from: value) let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>") - let components = value.components(separatedBy: invalidCharacters) + let components = transliterated.components(separatedBy: invalidCharacters) let collapsed = components.joined(separator: " ") .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedWhitespace = collapsed.replacingOccurrences( + let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " -_().,'&+")) + let filteredScalars = collapsed.unicodeScalars.map { scalar in + allowedCharacters.contains(scalar) ? Character(scalar) : " " + } + let filtered = String(filteredScalars) + + let normalizedWhitespace = filtered.replacingOccurrences( of: "\\s+", with: " ", options: .regularExpression ) + let trimmedPunctuation = normalizedWhitespace.trimmingCharacters(in: CharacterSet(charactersIn: " .-_")) - return normalizedWhitespace.isEmpty ? "Minecraft Content" : normalizedWhitespace + return trimmedPunctuation.isEmpty ? "Minecraft Content" : trimmedPunctuation + } + + nonisolated private static func portableASCIIString(from value: String) -> String { + let mutable = NSMutableString(string: value) as CFMutableString + + CFStringTransform(mutable, nil, kCFStringTransformToLatin, false) + CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false) + + return mutable as String } } diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 7b070eb..e6fabf5 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -133,66 +133,64 @@ final class SourceLibrary: ObservableObject { refreshSidebarFooterState() do { - let discoveredItems = try await Task.detached(priority: .userInitiated) { - try WorldScanner.discoverItems(in: sourceID) - }.value - - guard !Task.isCancelled else { - return + let enrichmentTracker = PendingEnrichmentTracker() + let applyEnrichedItem: @MainActor (MinecraftContentItem) -> Void = { [weak self] enrichedItem in + self?.handleEnrichedItem(enrichedItem, for: sourceID) } - - updateSource(sourceID) { source in - source.items = discoveredItems - source.indexedItemCount = discoveredItems.count - source.scanStatus = discoveredItems.isEmpty - ? "No Minecraft content found." - : "Found \(discoveredItems.count) items. Loading details..." - } - refreshSidebarFooterState() - - var loadedCount = 0 - - await withTaskGroup(of: MinecraftContentItem.self) { group in - for item in discoveredItems { - group.addTask { - WorldScanner.enrich(item: item) + let discoveryStream = AsyncThrowingStream { continuation in + let discoveryTask = Task.detached(priority: .userInitiated) { + do { + _ = try WorldScanner.discoverItems(in: sourceID) { item in + continuation.yield(item) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) } } - for await enrichedItem in group { - guard !Task.isCancelled else { - return - } - - loadedCount += 1 - updateSource(sourceID) { source in - guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else { - return - } - - source.items[index] = enrichedItem - source.indexedDetailCount = loadedCount - source.items.sort(by: WorldScanner.sortItems) - - if loadedCount == discoveredItems.count { - source.scanStatus = "Loaded \(loadedCount) items." - source.isScanning = false - source.lastScanDate = Date() - } else { - source.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..." - } - } - refreshSidebarFooterState() + continuation.onTermination = { @Sendable _ in + discoveryTask.cancel() } } - if discoveredItems.isEmpty { + var discoveredCount = 0 + + for try await item in discoveryStream { + guard !Task.isCancelled else { + break + } + + discoveredCount += 1 updateSource(sourceID) { source in - source.isScanning = false - source.lastScanDate = Date() + source.items.append(item) + source.indexedItemCount = discoveredCount + source.scanStatus = "Found \(discoveredCount) items. Loading details..." } refreshSidebarFooterState() + + await enrichmentTracker.beginEnrichment() + let tracker = enrichmentTracker + + Task.detached(priority: .utility) { + let enrichedItem = WorldScanner.enrich(item: item) + await applyEnrichedItem(enrichedItem) + await tracker.finishEnrichment() + } } + + await enrichmentTracker.markDiscoveryFinished() + await enrichmentTracker.waitForCompletion() + + updateSource(sourceID) { source in + source.items.sort(by: WorldScanner.sortItems) + source.scanStatus = source.indexedItemCount == 0 + ? "No Minecraft content found." + : "Loaded \(source.indexedDetailCount) items." + source.isScanning = false + source.lastScanDate = Date() + } + refreshSidebarFooterState() } catch { guard !Task.isCancelled else { return @@ -209,6 +207,22 @@ final class SourceLibrary: ObservableObject { scanTasks[sourceID] = nil } + private func handleEnrichedItem(_ enrichedItem: MinecraftContentItem, for sourceID: URL) { + updateSource(sourceID) { source in + guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else { + return + } + + source.items[index] = enrichedItem + source.indexedDetailCount += 1 + + if source.indexedDetailCount < source.indexedItemCount { + source.scanStatus = "Loaded details for \(source.indexedDetailCount) of \(source.indexedItemCount) items..." + } + } + refreshSidebarFooterState() + } + private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) { guard let index = sources.firstIndex(where: { $0.id == sourceID }) else { return @@ -268,3 +282,42 @@ final class SourceLibrary: ObservableObject { } } } + +private actor PendingEnrichmentTracker { + private var pendingCount = 0 + private var discoveryFinished = false + private var continuation: CheckedContinuation? + + func beginEnrichment() { + pendingCount += 1 + } + + func finishEnrichment() { + pendingCount -= 1 + resumeIfNeeded() + } + + func markDiscoveryFinished() { + discoveryFinished = true + resumeIfNeeded() + } + + func waitForCompletion() async { + guard !(discoveryFinished && pendingCount == 0) else { + return + } + + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + private func resumeIfNeeded() { + guard discoveryFinished, pendingCount == 0 else { + return + } + + continuation?.resume() + continuation = nil + } +} diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index a37a36f..2d736f0 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -8,7 +8,10 @@ import Foundation enum WorldScanner { - nonisolated static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] { + nonisolated static func discoverItems( + in searchRootURL: URL, + onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in } + ) throws -> [MinecraftContentItem] { let fileManager = FileManager.default let resourceKeys: [URLResourceKey] = [.isDirectoryKey] @@ -40,15 +43,15 @@ enum WorldScanner { } if isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) { - seenItemURLs.insert(itemURL) - discoveredItems.append( - MinecraftContentItem( - folderURL: childDirectory, - folderName: childDirectory.lastPathComponent, - contentType: contentType, - collectionRootURL: directoryURL - ) + let item = MinecraftContentItem( + folderURL: childDirectory, + folderName: childDirectory.lastPathComponent, + contentType: contentType, + collectionRootURL: directoryURL ) + seenItemURLs.insert(itemURL) + discoveredItems.append(item) + onDiscovered(item) } } } @@ -157,6 +160,19 @@ enum WorldScanner { return name } + nonisolated private static func packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? { + let candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"] + + for candidateName in candidateNames { + let candidateURL = directoryURL.appendingPathComponent(candidateName) + if fileManager.fileExists(atPath: candidateURL.path) { + return candidateURL + } + } + + return nil + } + nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? { let candidateNames: [String] @@ -286,20 +302,21 @@ enum WorldScanner { return jsonObject.compactMap { entry in let uuid = (entry["pack_id"] as? String)?.lowercased() let version = versionString(from: entry["version"]) - let resolvedName = uuid.flatMap { - resolvedPackName( + let resolvedPack = uuid.flatMap { + resolvedPackReference( uuid: $0, type: type, worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(), fileManager: fileManager ) } - let fallbackName = resolvedName ?? uuid ?? "Referenced Pack" + let fallbackName = resolvedPack?.name ?? uuid ?? "Referenced Pack" return ContentPackReference( name: fallbackName, type: type, + iconURL: resolvedPack?.iconURL, uuid: uuid, - version: version, + version: resolvedPack?.version ?? version, source: .referencedByWorld ) } @@ -352,18 +369,19 @@ enum WorldScanner { return ContentPackReference( name: name, type: type, + iconURL: packIconURL(in: directoryURL, fileManager: fileManager), uuid: uuid, version: version, source: source ) } - nonisolated private static func resolvedPackName( + nonisolated private static func resolvedPackReference( uuid: String, type: MinecraftContentType, worldCollectionRootURL: URL, fileManager: FileManager - ) -> String? { + ) -> ContentPackReference? { let siblingCollectionURL = worldCollectionRootURL .deletingLastPathComponent() .appendingPathComponent(type.collectionFolderName, isDirectory: true) @@ -388,7 +406,7 @@ enum WorldScanner { continue } - return reference.name + return reference } return nil diff --git a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift index ea40390..5023be6 100644 --- a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift +++ b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift @@ -12,6 +12,8 @@ struct World_Manager_for_MinecraftApp: App { var body: some Scene { WindowGroup { ContentView() + .tint(Color("AccentColor")) } + .windowToolbarStyle(.unifiedCompact(showsTitle: false)) } }