diff --git a/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift b/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift index 8c6daa2..f54abe6 100644 --- a/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/Content/MinecraftContentItem.swift @@ -265,15 +265,18 @@ nonisolated struct BedrockContentMetadata: Hashable, Sendable, Codable { nonisolated struct JavaContentMetadata: Hashable, Sendable, Codable { var world: JavaWorldMetadata? var pack: JavaPackMetadata? + var mod: JavaModMetadata? var dataPacks: [JavaPackReference] nonisolated init( world: JavaWorldMetadata? = nil, pack: JavaPackMetadata? = nil, + mod: JavaModMetadata? = nil, dataPacks: [JavaPackReference] = [] ) { self.world = world self.pack = pack + self.mod = mod self.dataPacks = dataPacks } } @@ -288,7 +291,46 @@ nonisolated struct JavaWorldMetadata: Hashable, Sendable, Codable { nonisolated struct JavaPackMetadata: Hashable, Sendable, Codable { var packFormat: Int? + var supportedFormats: String? var description: String? + + nonisolated init( + packFormat: Int? = nil, + supportedFormats: String? = nil, + description: String? = nil + ) { + self.packFormat = packFormat + self.supportedFormats = supportedFormats + self.description = description + } +} + +nonisolated struct JavaModMetadata: Hashable, Sendable, Codable { + var modID: String? + var version: String? + var description: String? + var authors: [String] + var license: String? + var environment: String? + var minecraftVersionRequirement: String? + + nonisolated init( + modID: String? = nil, + version: String? = nil, + description: String? = nil, + authors: [String] = [], + license: String? = nil, + environment: String? = nil, + minecraftVersionRequirement: String? = nil + ) { + self.modID = modID + self.version = version + self.description = description + self.authors = authors + self.license = license + self.environment = environment + self.minecraftVersionRequirement = minecraftVersionRequirement + } } nonisolated struct JavaPackReference: Identifiable, Hashable, Sendable, Codable { @@ -434,6 +476,23 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab values.append(packMetadataDetails?.minimumEngineVersion ?? "") values.append(packReferences.map(\.name).joined(separator: " ")) values.append(packReferences.compactMap(\.uuid).joined(separator: " ")) + if case .java(let metadata) = platformMetadata { + values.append(metadata.world?.dataVersion ?? "") + values.append(metadata.world?.gameMode ?? "") + values.append(metadata.world?.difficulty ?? "") + values.append(metadata.world?.seed ?? "") + values.append(metadata.pack?.description ?? "") + values.append(metadata.pack?.packFormat.map(String.init) ?? "") + values.append(metadata.pack?.supportedFormats ?? "") + values.append(metadata.mod?.modID ?? "") + values.append(metadata.mod?.version ?? "") + values.append(metadata.mod?.description ?? "") + values.append(metadata.mod?.authors.joined(separator: " ") ?? "") + values.append(metadata.mod?.license ?? "") + values.append(metadata.mod?.environment ?? "") + values.append(metadata.mod?.minecraftVersionRequirement ?? "") + values.append(metadata.dataPacks.map(\.name).joined(separator: " ")) + } return values .filter { !$0.isEmpty } diff --git a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift index 98c4887..0549552 100644 --- a/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift +++ b/World Manager for Minecraft/Services/AppSupport/Scanning/WorldScanner.swift @@ -788,8 +788,11 @@ enum JavaContentScanner { let metadata = JavaContentMetadataReader.metadata(for: item) enrichedItem.displayName = metadata?.displayName ?? displayName(for: item) enrichedItem.iconURL = await JavaContentMetadataReader.cachedIconURL(for: item, metadata: metadata) - if let packMetadata = metadata?.pack { - enrichedItem.platformMetadata = .java(JavaContentMetadata(pack: packMetadata)) + if metadata?.pack != nil || metadata?.mod != nil { + enrichedItem.platformMetadata = .java(JavaContentMetadata( + pack: metadata?.pack, + mod: metadata?.mod + )) } enrichedItem.hasKnownIcon = enrichedItem.iconURL != nil enrichedItem.modifiedDate = WorldScanner.modifiedDate(for: item.folderURL) diff --git a/World Manager for Minecraft/Services/ArchiveInspection/JavaContentMetadataReader.swift b/World Manager for Minecraft/Services/ArchiveInspection/JavaContentMetadataReader.swift index a0d8d64..ca1d0eb 100644 --- a/World Manager for Minecraft/Services/ArchiveInspection/JavaContentMetadataReader.swift +++ b/World Manager for Minecraft/Services/ArchiveInspection/JavaContentMetadataReader.swift @@ -6,6 +6,7 @@ import Foundation nonisolated struct JavaArchiveMetadata: Hashable, Sendable { var displayName: String? var pack: JavaPackMetadata? + var mod: JavaModMetadata? var iconEntryPath: String? } @@ -54,6 +55,7 @@ enum JavaContentMetadataReader { return JavaArchiveMetadata( displayName: nil, pack: pack, + mod: nil, iconEntryPath: iconURL?.lastPathComponent ) } @@ -74,6 +76,7 @@ enum JavaContentMetadataReader { return JavaArchiveMetadata( displayName: modMetadata?.displayName, pack: pack, + mod: modMetadata?.metadata, iconEntryPath: iconEntryPath ) } @@ -128,11 +131,14 @@ enum JavaContentMetadataReader { return JavaPackMetadata( packFormat: packObject["pack_format"] as? Int, + supportedFormats: supportedFormatsValue(from: packObject["supported_formats"]), description: textValue(from: packObject["description"]) ) } - nonisolated private static func modMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? { + nonisolated private static func modMetadata( + from archive: ZipArchiveReader + ) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? { if let tomlMetadata = modTOMLMetadata(from: archive) { return tomlMetadata } @@ -148,7 +154,9 @@ enum JavaContentMetadataReader { return nil } - nonisolated private static func modTOMLMetadata(from archive: ZipArchiveReader) -> (displayName: String?, iconPath: String?)? { + nonisolated private static func modTOMLMetadata( + from archive: ZipArchiveReader + ) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? { let entryNames = ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"] for entryName in entryNames { guard @@ -160,10 +168,20 @@ enum JavaContentMetadataReader { } let firstModSection = firstTOMLSection(named: "[[mods]]", in: text) + let dependenciesSection = firstTOMLSection(named: "[[dependencies.", in: text) let displayName = tomlStringValue(forKey: "displayName", in: firstModSection) let logoFile = tomlStringValue(forKey: "logoFile", in: firstModSection) - if displayName != nil || logoFile != nil { - return (displayName, logoFile) + let metadata = JavaModMetadata( + modID: tomlStringValue(forKey: "modId", in: firstModSection), + version: tomlStringValue(forKey: "version", in: firstModSection), + description: tomlStringValue(forKey: "description", in: firstModSection), + authors: stringListValue(from: tomlStringValue(forKey: "authors", in: firstModSection)), + license: tomlStringValue(forKey: "license", in: text), + environment: nil, + minecraftVersionRequirement: minecraftDependencyRequirement(fromTOMLSection: dependenciesSection) + ) + if displayName != nil || logoFile != nil || metadata.hasValues { + return (displayName, logoFile, metadata) } } @@ -173,7 +191,7 @@ enum JavaContentMetadataReader { nonisolated private static func modJSONMetadata( from archive: ZipArchiveReader, entryName: String - ) -> (displayName: String?, iconPath: String?)? { + ) -> (displayName: String?, iconPath: String?, metadata: JavaModMetadata)? { guard let entry = archive.entry(named: entryName), let data = try? archive.extract(entry), @@ -191,9 +209,20 @@ enum JavaContentMetadataReader { iconPath = nil } + let metadata = JavaModMetadata( + modID: (jsonObject["id"] as? String)?.nilIfBlank, + version: (jsonObject["version"] as? String)?.nilIfBlank, + description: textValue(from: jsonObject["description"]), + authors: authorsValue(from: jsonObject["authors"]), + license: licenseValue(from: jsonObject["license"]), + environment: (jsonObject["environment"] as? String)?.nilIfBlank, + minecraftVersionRequirement: minecraftDependencyRequirement(fromJSON: jsonObject) + ) + return ( (jsonObject["name"] as? String)?.nilIfBlank, - iconPath?.nilIfBlank + iconPath?.nilIfBlank, + metadata ) } @@ -231,6 +260,75 @@ enum JavaContentMetadataReader { return nil } + nonisolated private static func minecraftDependencyRequirement(fromTOMLSection text: String) -> String? { + guard tomlStringValue(forKey: "modId", in: text) == "minecraft" else { + return nil + } + + return tomlStringValue(forKey: "versionRange", in: text) + } + + nonisolated private static func minecraftDependencyRequirement(fromJSON jsonObject: [String: Any]) -> String? { + for key in ["depends", "dependencies", "breaks"] { + guard let dependencies = jsonObject[key] as? [String: Any] else { + continue + } + + if let minecraft = dependencies["minecraft"] as? String { + return minecraft.nilIfBlank + } + if let minecraft = dependencies["minecraft"] as? [String: Any] { + return textValue(from: minecraft["version"]) + } + } + + return nil + } + + nonisolated private static func authorsValue(from value: Any?) -> [String] { + if let author = value as? String { + return stringListValue(from: author) + } + + if let authors = value as? [String] { + return authors.compactMap(\.nilIfBlank) + } + + if let authors = value as? [[String: Any]] { + return authors.compactMap { author in + textValue(from: author["name"]) + } + } + + return [] + } + + nonisolated private static func licenseValue(from value: Any?) -> String? { + if let license = value as? String { + return license.nilIfBlank + } + + if let licenses = value as? [String] { + let values = licenses.compactMap(\.nilIfBlank) + return values.isEmpty ? nil : values.joined(separator: ", ") + } + + return nil + } + + nonisolated private static func stringListValue(from value: String?) -> [String] { + guard let value else { + return [] + } + + return value + .split { character in + character == "," || character == ";" + } + .map(String.init) + .compactMap(\.nilIfBlank) + } + nonisolated private static func iconEntryPath( in archive: ZipArchiveReader, preferredPath: String?, @@ -275,6 +373,45 @@ enum JavaContentMetadataReader { return nil } + + nonisolated private static func supportedFormatsValue(from value: Any?) -> String? { + if let format = value as? Int { + return String(format) + } + + if let formats = value as? [Int] { + return formats.map(String.init).joined(separator: ", ").nilIfBlank + } + + if let object = value as? [String: Any] { + let minValue = object["min_inclusive"] as? Int + let maxValue = object["max_inclusive"] as? Int + switch (minValue, maxValue) { + case (.some(let minValue), .some(let maxValue)): + return "\(minValue)-\(maxValue)" + case (.some(let minValue), .none): + return "\(minValue)+" + case (.none, .some(let maxValue)): + return "Up to \(maxValue)" + case (.none, .none): + return nil + } + } + + return nil + } +} + +private extension JavaModMetadata { + nonisolated var hasValues: Bool { + modID != nil + || version != nil + || description != nil + || !authors.isEmpty + || license != nil + || environment != nil + || minecraftVersionRequirement != nil + } } private extension String { diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index 6f32eb8..773525f 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -59,28 +59,35 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(), connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil, notificationService: ScanNotificationServicing? = nil, - itemActionService: ContentItemActionService = ContentItemActionService() + itemActionService: ContentItemActionService = ContentItemActionService(), + restoresPersistedSources: Bool = true, + startsBackgroundRefresh: Bool = true ) { self.persistenceStore = persistenceStore self.sourceAccessMethod = sourceAccessMethod self.connectedDeviceAccessMethod = connectedDeviceAccessMethod self.notificationService = notificationService ?? ScanNotificationService.shared self.itemActionService = itemActionService + self.isRestoringPersistedSources = restoresPersistedSources - Task { [weak self] in - guard let self else { - return + if restoresPersistedSources { + Task { [weak self] in + guard let self else { + return + } + await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore) } - await SourcePersistenceCoordinator.restoreSources(on: self, using: self.persistenceStore) } - localSourceRefreshTask = Task { [weak self] in - await self?.runLocalSourceRefreshLoop() - } + if startsBackgroundRefresh { + localSourceRefreshTask = Task { [weak self] in + await self?.runLocalSourceRefreshLoop() + } - if connectedDeviceAccessMethod != nil { - connectedDeviceRefreshTask = Task { [weak self] in - await self?.runConnectedDeviceRefreshLoop() + if connectedDeviceAccessMethod != nil { + connectedDeviceRefreshTask = Task { [weak self] in + await self?.runConnectedDeviceRefreshLoop() + } } } } diff --git a/World Manager for Minecraft/UI/Detail/ItemDetailView.swift b/World Manager for Minecraft/UI/Detail/ItemDetailView.swift index 74d7aaa..1dac30f 100644 --- a/World Manager for Minecraft/UI/Detail/ItemDetailView.swift +++ b/World Manager for Minecraft/UI/Detail/ItemDetailView.swift @@ -162,7 +162,7 @@ struct ItemDetailView: View { recordSection(title: "Technical Details") { VStack(alignment: .leading, spacing: 14) { detailRow(title: "Folder ID", value: item.folderID) - detailRow(title: "Type", value: item.contentType.rawValue) + detailRow(title: "Type", value: item.platformType.displayName) detailRow(title: "Collection Folder", value: item.collectionRootURL.lastPathComponent) if let spawn = item.worldMetadata?.spawn { detailValueRow(title: "Spawn", value: spawn) @@ -249,13 +249,49 @@ struct ItemDetailView: View { ) } - if item.contentType == .behaviorPack || item.contentType == .resourcePack { + if item.sourceEdition == .bedrock && (item.contentType == .behaviorPack || item.contentType == .resourcePack) { detailValueRow(title: "UUID", value: item.packUUID ?? "Unavailable") detailValueRow(title: "Version", value: item.packVersion ?? "Unavailable") if let minimumEngineVersion = item.packMetadataDetails?.minimumEngineVersion { detailValueRow(title: "Minimum Engine", value: minimumEngineVersion) } } + + if let javaPackMetadata { + if let description = javaPackMetadata.description { + detailRow(title: javaModMetadata == nil ? "Description" : "Pack Description", value: description) + } + if let packFormat = javaPackMetadata.packFormat { + detailValueRow(title: "Pack Format", value: String(packFormat)) + } + if let supportedFormats = javaPackMetadata.supportedFormats { + detailValueRow(title: "Supported Formats", value: supportedFormats) + } + } + + if let javaModMetadata { + if let modID = javaModMetadata.modID { + detailValueRow(title: "Mod ID", value: modID) + } + if let version = javaModMetadata.version { + detailValueRow(title: "Mod Version", value: version) + } + if let description = javaModMetadata.description { + detailRow(title: "Mod Description", value: description) + } + if !javaModMetadata.authors.isEmpty { + detailValueRow(title: "Authors", value: javaModMetadata.authors.joined(separator: ", ")) + } + if let license = javaModMetadata.license { + detailValueRow(title: "License", value: license) + } + if let environment = javaModMetadata.environment { + detailValueRow(title: "Environment", value: environment) + } + if let minecraftRequirement = javaModMetadata.minecraftVersionRequirement { + detailValueRow(title: "Minecraft", value: minecraftRequirement) + } + } } } @@ -349,7 +385,13 @@ struct ItemDetailView: View { } private var heroMetadata: [String] { - var chips = [item.contentType.rawValue, sizeText, "\(item.displayDateLabel) \(displayDateText)"] + var chips = [item.platformType.displayName, sizeText, "\(item.displayDateLabel) \(displayDateText)"] + + if let modID = javaModMetadata?.modID { + chips.append(modID) + } else if let packFormat = javaPackMetadata?.packFormat { + chips.append("Format \(packFormat)") + } if item.contentType == .world { let packCount = behaviorPacks.count + resourcePacks.count @@ -388,6 +430,22 @@ struct ItemDetailView: View { return max(0, relatedWorldIDs.subtracting([item.id]).count) } + private var javaPackMetadata: JavaPackMetadata? { + if case .java(let metadata) = item.platformMetadata { + return metadata.pack + } + + return nil + } + + private var javaModMetadata: JavaModMetadata? { + if case .java(let metadata) = item.platformMetadata { + return metadata.mod + } + + return nil + } + private var actionRowExportTitle: String { if exportTitle != nil { switch item.contentType { diff --git a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift index 6967c0e..f338d14 100644 --- a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift +++ b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift @@ -73,8 +73,8 @@ struct ItemListColumnView: View { sourceName: sourceName, showsSourceName: showsSourceName, title: title, - subtitle: subtitle, - showsSubtitle: showsSubtitle, + subtitle: navigationSubtitleText, + showsSubtitle: showsSubtitle || showsProjectionLoadingState, isRefreshing: isRefreshing, showsProjectionLoadingState: showsProjectionLoadingState ) @@ -82,7 +82,7 @@ struct ItemListColumnView: View { } .searchable(text: $searchText, prompt: searchPrompt) .navigationTitle(isEmpty ? "Library" : title) - .navigationSubtitle(isEmpty ? "" : subtitle) + .navigationSubtitle(isEmpty ? "" : navigationSubtitleText) .toolbar { if !isEmpty { ToolbarItemGroup { @@ -100,6 +100,13 @@ struct ItemListColumnView: View { } } } + + private var navigationSubtitleText: String { + if showsProjectionLoadingState { + return "Loading items..." + } + return subtitle + } } private struct ItemListHeaderView: View { @@ -130,8 +137,8 @@ private struct ItemListHeaderView: View { } } - if showsSubtitle || showsProjectionLoadingState { - Text(displaySubtitle) + if showsSubtitle { + Text(subtitle) .appTextStyle(.supporting) } } @@ -141,14 +148,6 @@ private struct ItemListHeaderView: View { .padding(.bottom, 12) .appListHeaderSurface() } - - private var displaySubtitle: String { - if showsProjectionLoadingState { - return "Loading items..." - } - - return subtitle - } } private struct ItemListLoadingOverlay: View { diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index 51b13f8..e191522 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -6,7 +6,7 @@ import SwiftUI #if DEBUG -enum PreviewFixtures { +nonisolated enum PreviewFixtures { static let baseDate = Date(timeIntervalSinceReferenceDate: 770_000_000) static let sourceOneURL = URL(fileURLWithPath: "/tmp/preview-library-1") @@ -132,7 +132,7 @@ enum PreviewFixtures { ) static let primarySource: MinecraftSource = { - var source = MinecraftSource(folderURL: sourceOneURL) + var source = MinecraftSource(folderURL: sourceOneURL, availability: .available) source.displayName = "Kid iPad Imports" source.displayItems = [ featuredWorld, @@ -226,7 +226,7 @@ enum PreviewFixtures { }() static let secondarySource: MinecraftSource = { - var source = MinecraftSource(folderURL: sourceTwoURL) + var source = MinecraftSource(folderURL: sourceTwoURL, availability: .available) source.displayName = "Downloads" source.displayItems = [secondLibraryPack] source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in @@ -253,6 +253,87 @@ enum PreviewFixtures { ] } +struct PreviewSourceAccess: SourceAccessMethod { + nonisolated let accessorIdentifier: SourceAccessorIdentifier = "preview-source" + + nonisolated init() {} + + nonisolated func accessStatus(for source: MinecraftSource) async -> SourceAccessStatus { + SourceAccessStatus( + availability: .available, + mode: .localFileSystem, + displayName: source.displayName, + iconSystemName: "folder", + statusText: nil, + warningText: nil + ) + } + + nonisolated func capabilities(for source: MinecraftSource) async -> SourceCapabilities { + _ = source + return .localFolder + } + + nonisolated func discoverItems( + for source: MinecraftSource, + mode: SourceDiscoveryMode, + onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void + ) async throws { + _ = mode + for item in source.displayItems { + onDiscovered(item) + } + } + + nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryEntry] { + _ = item + _ = source + return PreviewFixtures.directoryEntries + } + + nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL { + _ = source + return item.folderURL + } +} + +@MainActor +extension SourceLibrary { + static func makePreview() -> SourceLibrary { + let library = SourceLibrary( + sourceAccessMethod: PreviewSourceAccess(), + restoresPersistedSources: false, + startsBackgroundRefresh: false + ) + library.sources = PreviewFixtures.allSources + library.sourceCandidates = [ + SourceCandidate( + providerID: LocalFolderSourceAccess().accessorIdentifier, + edition: .bedrock, + sourceRootURL: URL(fileURLWithPath: "/tmp/preview-candidate"), + displayName: "Found Minecraft Folder", + confidence: .strong, + reason: "Contains Minecraft content folders", + detectedKinds: [.world, .resourcePack] + ) + ] + return library + } +} + +extension ContentViewDependencies { + @MainActor + static func makePreview() -> ContentViewDependencies { + let connectedDeviceAccess = AppleMobileDeviceSourceAccess() + return ContentViewDependencies( + library: .makePreview(), + connectedDeviceAccess: connectedDeviceAccess, + deviceSourceFactory: ConnectedDeviceSourceFactory(), + itemActionService: ContentItemActionService() + ) + } +} + @MainActor struct SidebarColumnPreviewContainer: View { @State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id) diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index 12b4bea..f828be7 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -32,14 +32,19 @@ struct ContentView: View { private let directoryPreviewLimit = 12 private let projectionLoadingDelay: Duration = .milliseconds(150) - init() { - let dependencies = ContentViewDependencies.makeDefault() + init( + dependencies: ContentViewDependencies = ContentViewDependencies.makeDefault(), + initialSidebarSelection: SidebarSelection? = nil, + initialItemID: MinecraftContentItem.ID? = nil + ) { self.connectedDeviceAccess = dependencies.connectedDeviceAccess self.deviceSourceFactory = dependencies.deviceSourceFactory self.itemActionService = dependencies.itemActionService _library = StateObject( wrappedValue: dependencies.library ) + _selectedSidebarSelection = State(initialValue: initialSidebarSelection) + _selectedItemID = State(initialValue: initialItemID) } var body: some View { @@ -919,8 +924,19 @@ struct ContentView: View { } } +#if DEBUG struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() + ContentView( + dependencies: .makePreview(), + initialSidebarSelection: .contentKind( + sourceID: PreviewFixtures.primarySource.id, + contentKind: .world + ), + initialItemID: PreviewFixtures.featuredWorld.id + ) + .frame(width: 1_440, height: 900) + .previewDisplayName("Full Window") } } +#endif diff --git a/World Manager for Minecraft/UI/Shared/ContentUIShared.swift b/World Manager for Minecraft/UI/Shared/ContentUIShared.swift index 71f99d5..0258d00 100644 --- a/World Manager for Minecraft/UI/Shared/ContentUIShared.swift +++ b/World Manager for Minecraft/UI/Shared/ContentUIShared.swift @@ -160,6 +160,7 @@ private struct AppTransportBadgeBubbleModifier: ViewModifier { enum AppCapsuleLabelStyle { case sidebarSubtle case sidebarAccent + case sidebarSelected case heroMetadata } @@ -181,6 +182,8 @@ private struct AppCapsuleLabelModifier: ViewModifier { return AnyShapeStyle(.secondary) case .sidebarAccent: return AnyShapeStyle(Color.appAccent) + case .sidebarSelected: + return AnyShapeStyle(.white.opacity(0.92)) case .heroMetadata: return AnyShapeStyle(.white.opacity(0.95)) } @@ -192,6 +195,8 @@ private struct AppCapsuleLabelModifier: ViewModifier { return AnyShapeStyle(.secondary.opacity(0.12)) case .sidebarAccent: return AnyShapeStyle(Color.appAccent.opacity(0.14)) + case .sidebarSelected: + return AnyShapeStyle(.white.opacity(0.16)) case .heroMetadata: return AnyShapeStyle(.white.opacity(0.14)) } @@ -201,7 +206,7 @@ private struct AppCapsuleLabelModifier: ViewModifier { switch style { case .heroMetadata: return 10 - case .sidebarSubtle, .sidebarAccent: + case .sidebarSubtle, .sidebarAccent, .sidebarSelected: return 7 } } @@ -210,7 +215,7 @@ private struct AppCapsuleLabelModifier: ViewModifier { switch style { case .heroMetadata: return 7 - case .sidebarSubtle, .sidebarAccent: + case .sidebarSubtle, .sidebarAccent, .sidebarSelected: return 4 } } diff --git a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift index 6bd8a1d..e474814 100644 --- a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift +++ b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift @@ -86,6 +86,9 @@ struct SourcesSidebarView: View { } } .listStyle(.sidebar) + .transaction { transaction in + transaction.animation = nil + } .toolbar { ToolbarItem { Button(action: discoverSourcesAction) { @@ -158,10 +161,9 @@ struct SourcesSidebarView: View { private func sidebarNodeRow(_ node: SidebarNode) -> some View { switch node.row { case .source(let source): - SourceHeaderRow(source: source) + SourceHeaderRow(source: source, isSelected: selection == node.selection) .tag(node.selection as SidebarSelection?) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .contextMenu { Button("Rescan \"\(source.displayName)\"") { rescanSourceAction(source) @@ -185,7 +187,6 @@ struct SourcesSidebarView: View { ) .tag(node.selection as SidebarSelection?) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8)) case .sourceCandidate(let candidate): SourceCandidateRow( candidate: candidate, @@ -195,7 +196,6 @@ struct SourcesSidebarView: View { ) .tag(node.selection as SidebarSelection?) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) } } } @@ -206,18 +206,19 @@ private struct SourceCandidateRow: View { var body: some View { HStack(spacing: 8) { - Image(systemName: symbolName) - .foregroundStyle(.secondary) - .frame(width: 16) + Label { + VStack(alignment: .leading, spacing: 2) { + Text(candidate.displayName) + .lineLimit(1) - VStack(alignment: .leading, spacing: 2) { - Text(candidate.displayName) - .lineLimit(1) - - Text(subtitle) - .font(.caption) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } icon: { + Image(systemName: symbolName) .foregroundStyle(.secondary) - .lineLimit(1) } Spacer(minLength: 8) @@ -250,12 +251,13 @@ private struct SidebarFilterRow: View { let filter: SidebarFilter var body: some View { - HStack(spacing: 10) { - Image(systemName: filter.iconName) - .frame(width: 16) - .foregroundStyle(.secondary) - - Text(filter.title) + HStack { + Label { + Text(filter.title) + } icon: { + Image(systemName: filter.iconName) + .foregroundStyle(.secondary) + } Spacer() @@ -275,24 +277,31 @@ private struct SidebarSourcesSectionHeaderView: View { private struct SourceHeaderRow: View { let source: MinecraftSource + let isSelected: Bool var body: some View { - HStack(spacing: 8) { - Image(systemName: headerSymbolName) - .foregroundStyle(.secondary) - - Text(source.displayName) - .lineLimit(1) + HStack { + Label { + Text(source.displayName) + .lineLimit(1) + } icon: { + Image(systemName: headerSymbolName) + .foregroundStyle(.secondary) + } Spacer(minLength: 8) HStack(spacing: 8) { if let availabilityBadgeText { - SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis) + SourceAvailabilityBadge( + text: availabilityBadgeText, + emphasis: availabilityBadgeEmphasis, + isSelected: isSelected + ) } if let connection { - SourceConnectionBadge(connection: connection) + SourceConnectionBadge(connection: connection, isSelected: isSelected) } if showsStatusAccessory { @@ -301,8 +310,6 @@ private struct SourceHeaderRow: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.vertical, 5) } private var connection: DeviceConnection? { @@ -351,23 +358,24 @@ private struct SourceHeaderRow: View { @ViewBuilder private var statusAccessory: some View { - if source.isScanning { - if let scanProgress = source.scanProgress { - CircularScanProgressView(progress: scanProgress) - } else { - ProgressView() - .appActivityIndicatorStyle(.small) - } + if source.isScanning { + if let scanProgress = source.scanProgress { + CircularScanProgressView(progress: scanProgress, isSelected: isSelected) + } else { + ProgressView() + .appActivityIndicatorStyle(.small) } } + } } private struct SourceConnectionBadge: View { let connection: DeviceConnection + let isSelected: Bool var body: some View { Image(systemName: symbolName) - .appCapsuleLabelStyle(.sidebarSubtle) + .appCapsuleLabelStyle(isSelected ? .sidebarSelected : .sidebarSubtle) .help(helpText) .accessibilityLabel(helpText) } @@ -394,30 +402,35 @@ private struct SourceConnectionBadge: View { private struct SourceAvailabilityBadge: View { let text: String let emphasis: Bool + let isSelected: Bool var body: some View { Text(text) - .appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle) + .appCapsuleLabelStyle(isSelected ? .sidebarSelected : emphasis ? .sidebarAccent : .sidebarSubtle) } } private struct CircularScanProgressView: View { let progress: Double + let isSelected: Bool + + private let size: CGFloat = 17 + private let lineWidth: CGFloat = 1.4 var body: some View { ZStack { Circle() - .stroke(.secondary.opacity(0.18), lineWidth: 3) + .stroke(isSelected ? .white.opacity(0.18) : Color.secondary.opacity(0.24), lineWidth: lineWidth) Circle() - .trim(from: 0, to: max(0.02, min(progress, 1))) + .trim(from: 0, to: max(0, min(progress, 1))) .stroke( - Color.appAccent, - style: StrokeStyle(lineWidth: 3, lineCap: .round) + isSelected ? .white.opacity(0.86) : Color.appAccent, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) ) .rotationEffect(.degrees(-90)) } - .frame(width: 18, height: 18) + .frame(width: size, height: size) .accessibilityElement(children: .ignore) .accessibilityLabel("Scan progress") .accessibilityValue(Text("\(Int((progress * 100).rounded())) percent")) @@ -429,20 +442,22 @@ private struct ConnectedDeviceRow: View { let addAction: (() -> Void)? var body: some View { - HStack(alignment: .top, spacing: 10) { - ConnectedDeviceTransportIcon( - baseSymbolName: iconName, - connection: entry.device.connection, - tint: iconColor - ) + HStack(alignment: .top) { + Label { + VStack(alignment: .leading, spacing: 4) { + Text(entry.device.name) + .appTextStyle(.rowTitle) + .foregroundStyle(titleColor) - VStack(alignment: .leading, spacing: 4) { - Text(entry.device.name) - .appTextStyle(.rowTitle) - .foregroundStyle(titleColor) - - Text(statusText) - .appTextStyle(.supportingCompact) + Text(statusText) + .appTextStyle(.supportingCompact) + } + } icon: { + ConnectedDeviceTransportIcon( + baseSymbolName: iconName, + connection: entry.device.connection, + tint: iconColor + ) } Spacer(minLength: 12) diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index d7c86d8..6d3303d 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -250,9 +250,15 @@ struct World_Manager_for_MinecraftTests { [[mods]] modId = "examplemod" + version = "1.2.3" displayName = "Example Java Mod" logoFile = "icon.png" + authors = "Alex, Sam" description = "A test mod." + + [[dependencies.examplemod]] + modId = "minecraft" + versionRange = "[1.21,)" """.write( to: modSourceURL.appendingPathComponent("META-INF/neoforge.mods.toml"), atomically: true, @@ -274,7 +280,11 @@ struct World_Manager_for_MinecraftTests { { "pack": { "description": "Example Resource Pack", - "pack_format": 34 + "pack_format": 34, + "supported_formats": { + "min_inclusive": 34, + "max_inclusive": 42 + } } } """.write(to: resourceSourceURL.appendingPathComponent("pack.mcmeta"), atomically: true, encoding: .utf8) @@ -313,6 +323,11 @@ struct World_Manager_for_MinecraftTests { if case .java(let metadata) = enrichedMod.platformMetadata { #expect(metadata.pack?.description == "Example Mod Resources") #expect(metadata.pack?.packFormat == 31) + #expect(metadata.mod?.modID == "examplemod") + #expect(metadata.mod?.version == "1.2.3") + #expect(metadata.mod?.description == "A test mod.") + #expect(metadata.mod?.authors == ["Alex", "Sam"]) + #expect(metadata.mod?.minecraftVersionRequirement == "[1.21,)") } else { Issue.record("Expected Java metadata") } @@ -321,6 +336,7 @@ struct World_Manager_for_MinecraftTests { if case .java(let metadata) = enrichedResource.platformMetadata { #expect(metadata.pack?.description == "Example Resource Pack") #expect(metadata.pack?.packFormat == 34) + #expect(metadata.pack?.supportedFormats == "34-42") } else { Issue.record("Expected Java metadata") }