From 34e10a2075507fd5184023f5d0d4f649ad5aad9b Mon Sep 17 00:00:00 2001 From: John Burwell Date: Sat, 30 May 2026 00:32:53 -0500 Subject: [PATCH] Refine sidebar and detail loading behavior --- .../Models/Sources/MinecraftSource.swift | 25 ++ .../Services/Sources/Core/SourceLibrary.swift | 14 +- .../SourcePersistenceCoordinator.swift | 26 +- .../Persistence/SourceRestoration.swift | 3 + .../Sources/Scanning/SourceContentIndex.swift | 20 +- .../Scanning/SourceScanExecution.swift | 8 + .../Sources/Scanning/SourceScanning.swift | 1 + .../ConnectedDeviceSourceFactory.swift | 4 +- .../UI/Detail/ItemDetailView.swift | 34 +-- .../UI/Detail/SourceDetailView.swift | 46 +-- .../UI/List/ItemListColumnViews.swift | 72 +++-- .../UI/Preview/PreviewFixtures.swift | 8 +- .../UI/Root/ContentView.swift | 265 ++++++------------ .../UI/Root/ItemCollectionProjection.swift | 189 +++++++++++++ .../UI/Shared/ContentUIShared.swift | 229 +++++++++++++-- .../UI/Sidebar/SidebarColumnViews.swift | 130 +++------ 16 files changed, 695 insertions(+), 379 deletions(-) create mode 100644 World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift diff --git a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift index c1e00ab..7531150 100644 --- a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift @@ -17,6 +17,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { var bookmarkData: Data? var displayName: String var displayItems: [MinecraftContentItem] + var displayItemCountsByType: [MinecraftContentType: Int] var rawItems: [MinecraftContentItem] var logicalPacks: [LogicalPack] var logicalWorlds: [LogicalWorld] @@ -61,6 +62,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { self.bookmarkData = bookmarkData self.displayName = normalizedFolderURL.lastPathComponent self.displayItems = [] + self.displayItemCountsByType = [:] self.rawItems = [] self.logicalPacks = [] self.logicalWorlds = [] @@ -99,6 +101,29 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { displayItems } + func items(for contentType: MinecraftContentType) -> [MinecraftContentItem] { + displayItems.filter { $0.contentType == contentType } + } + + func items(matching selection: SidebarSelection?) -> [MinecraftContentItem] { + guard let selection else { + return [] + } + + switch selection { + case .source(let sourceID), .allContent(let sourceID): + guard sourceID == id else { + return [] + } + return displayItems + case .contentType(let sourceID, let contentType): + guard sourceID == id else { + return [] + } + return items(for: contentType) + } + } + func rawItem(withID itemID: URL) -> MinecraftContentItem? { rawItems.first(where: { $0.id == itemID }) } diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index 1d8e392..cf58c34 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -117,12 +117,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer return } - await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown( - from: visibleSources, - using: persistenceStore - ) + let sourcesToPersist = visibleSources shutdown() - try? await Task.sleep(for: .seconds(timeout)) + + await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown( + from: sourcesToPersist, + using: persistenceStore, + timeout: timeout + ) } func addSource(at url: URL) -> URL { @@ -303,6 +305,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer source.packInstances = index.packInstances source.worldPackRelationships = index.worldPackRelationships source.displayItems = index.displayItems + source.displayItemCountsByType = index.displayItemCountsByType } } @@ -344,6 +347,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) { updateSource(sourceID) { source in source.displayItems = snapshot.displayItems + source.displayItemCountsByType = snapshot.displayItemCountsByType source.rawItems = snapshot.rawItems source.logicalPacks = snapshot.logicalPacks source.logicalWorlds = snapshot.logicalWorlds diff --git a/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift b/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift index b7a20e8..36c4b83 100644 --- a/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift +++ b/World Manager for Minecraft/Services/Sources/Persistence/SourcePersistenceCoordinator.swift @@ -83,10 +83,30 @@ enum SourcePersistenceCoordinator { static func persistVisibleSourcesForShutdown( from sources: [MinecraftSource], - using persistenceStore: SourcePersistenceStore + using persistenceStore: SourcePersistenceStore, + timeout: TimeInterval ) async { - for source in sources { - try? await persistenceStore.save(source: source) + guard !sources.isEmpty else { + return + } + + await withTaskGroup(of: Void.self) { group in + group.addTask { + for source in sources { + guard !Task.isCancelled else { + return + } + + try? await persistenceStore.save(source: source) + } + } + + group.addTask { + try? await Task.sleep(for: .seconds(timeout)) + } + + await group.next() + group.cancelAll() } } diff --git a/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift b/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift index 89414ff..ecad247 100644 --- a/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift +++ b/World Manager for Minecraft/Services/Sources/Persistence/SourceRestoration.swift @@ -57,6 +57,9 @@ enum SourceRestoration { to source: inout MinecraftSource ) { source.rawItems = items + source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in + counts[item.contentType, default: 0] += 1 + } source.indexedItemCount = items.count source.indexedDetailCount = items.filter(\.metadataLoaded).count source.previewLoadedCount = items.filter(\.previewLoaded).count diff --git a/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift b/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift index 7cea782..68410a5 100644 --- a/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift +++ b/World Manager for Minecraft/Services/Sources/Scanning/SourceContentIndex.swift @@ -14,6 +14,7 @@ struct SourceContentIndex { let packInstances: [PackInstance] let worldPackRelationships: [WorldPackRelationship] let displayItems: [MinecraftContentItem] + let displayItemCountsByType: [MinecraftContentType: Int] } enum SourceContentIndexer { @@ -161,17 +162,20 @@ enum SourceContentIndexer { $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending } + let displayItems = buildDisplayItems( + from: rawItems, + logicalPacks: logicalPacks, + rawItemsByID: rawItemsByID + ) + return SourceContentIndex( rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: sortedLogicalWorlds, packInstances: sortedPackInstances, worldPackRelationships: worldRelationships, - displayItems: buildDisplayItems( - from: rawItems, - logicalPacks: logicalPacks, - rawItemsByID: rawItemsByID - ) + displayItems: displayItems, + displayItemCountsByType: displayItemCounts(for: displayItems) ) } @@ -213,6 +217,12 @@ enum SourceContentIndexer { return normalizedItems } + private static func displayItemCounts(for items: [MinecraftContentItem]) -> [MinecraftContentType: Int] { + items.reduce(into: [MinecraftContentType: Int]()) { counts, item in + counts[item.contentType, default: 0] += 1 + } + } + private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { let candidateEmbedded = isEmbeddedWorldPack(candidate) let existingEmbedded = isEmbeddedWorldPack(existing) diff --git a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift index 31eb5b8..119744b 100644 --- a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift +++ b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanExecution.swift @@ -467,6 +467,7 @@ private actor EnrichmentWorkQueue { struct SourceIndexSnapshot { let displayItems: [MinecraftContentItem] + let displayItemCountsByType: [MinecraftContentType: Int] let rawItems: [MinecraftContentItem] let logicalPacks: [LogicalPack] let logicalWorlds: [LogicalWorld] @@ -631,6 +632,9 @@ private actor SourceIndexActor { logicalPacks: logicalPacks, rawItemsByID: rawItemsByID ) + let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in + counts[item.contentType, default: 0] += 1 + } let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount) let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount) let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount) @@ -651,6 +655,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, + displayItemCountsByType: displayItemCountsByType, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: [], @@ -678,6 +683,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, + displayItemCountsByType: displayItemCountsByType, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: [], @@ -709,6 +715,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, + displayItemCountsByType: displayItemCountsByType, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: [], @@ -820,6 +827,7 @@ private actor SourceIndexActor { return SourceIndexSnapshot( displayItems: dedupedDisplayItems, + displayItemCountsByType: displayItemCountsByType, rawItems: rawItems, logicalPacks: logicalPacks, logicalWorlds: logicalWorlds, diff --git a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift index 7be1e49..82892c6 100644 --- a/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift +++ b/World Manager for Minecraft/Services/Sources/Scanning/SourceScanning.swift @@ -156,6 +156,7 @@ enum SourceScanPolicy { enum SourceScanRecovery { static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) { source.displayItems = previousSource.displayItems + source.displayItemCountsByType = previousSource.displayItemCountsByType source.rawItems = previousSource.rawItems source.logicalPacks = previousSource.logicalPacks source.logicalWorlds = previousSource.logicalWorlds diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift index 40a298a..5a2f351 100644 --- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift +++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourceFactory.swift @@ -31,8 +31,8 @@ struct ConnectedDeviceSourceFactory: Sendable { return source } - nonisolated func displayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String { - "\(device.name) • \(container.appName)" + nonisolated func displayName(for device: ConnectedDevice, container _: DeviceAppContainer) -> String { + device.name } nonisolated func makeSourceIdentifier(device: ConnectedDevice, container: DeviceAppContainer) -> URL { diff --git a/World Manager for Minecraft/UI/Detail/ItemDetailView.swift b/World Manager for Minecraft/UI/Detail/ItemDetailView.swift index ee53a9f..0cca9e4 100644 --- a/World Manager for Minecraft/UI/Detail/ItemDetailView.swift +++ b/World Manager for Minecraft/UI/Detail/ItemDetailView.swift @@ -135,7 +135,7 @@ struct ItemDetailView: View { if !contents.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Recent Folder Contents") - .font(.subheadline.weight(.semibold)) + .appTextStyle(.rowTitle) ForEach(contents) { entry in HStack(spacing: 10) { @@ -149,8 +149,7 @@ struct ItemDetailView: View { if contents.count == directoryPreviewLimit { Text("Showing the first \(directoryPreviewLimit) items") - .font(.caption) - .foregroundStyle(.secondary) + .appTextStyle(.fieldLabel) } } } @@ -433,17 +432,13 @@ struct ItemDetailView: View { @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 10) { - Text(title.uppercased()) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .tracking(0.5) + Text(title) + .appSectionTitleStyle(.section) VStack(alignment: .leading, spacing: 14) { content() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) - .appCardSurface(.secondaryPanel) + .appDetailSectionCard() } } @@ -451,7 +446,7 @@ struct ItemDetailView: View { private func packSection(title: String, packs: [ContentPackReference]) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) - .font(.subheadline.weight(.semibold)) + .appTextStyle(.rowTitle) ForEach(packs) { pack in recordListRow( @@ -466,14 +461,15 @@ struct ItemDetailView: View { @ViewBuilder private func detailRow(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 16) { Text(title) - .font(.caption) - .foregroundStyle(.secondary) + .appTextStyle(.emphasisLabel) + .frame(width: 170, alignment: .leading) Text(value) - .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } } @@ -481,7 +477,7 @@ struct ItemDetailView: View { private func detailValueRow(title: String, value: String) -> some View { HStack(alignment: .firstTextBaseline, spacing: 16) { Text(title) - .foregroundStyle(.secondary) + .appTextStyle(.emphasisLabel) Spacer() Text(value) .fontWeight(.medium) @@ -538,8 +534,7 @@ struct ItemDetailView: View { VStack(alignment: .leading, spacing: 8) { ForEach(values, id: \.self) { value in Label(value, systemImage: "checkmark.circle") - .font(.subheadline) - .foregroundStyle(.secondary) + .appTextStyle(.supporting) } } } @@ -586,8 +581,7 @@ struct ItemDetailView: View { if let subtitle, !subtitle.isEmpty { Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) + .appTextStyle(.fieldLabel) .lineLimit(3) } } diff --git a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift index 34ba936..44891c8 100644 --- a/World Manager for Minecraft/UI/Detail/SourceDetailView.swift +++ b/World Manager for Minecraft/UI/Detail/SourceDetailView.swift @@ -21,14 +21,8 @@ struct SourceDetailView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { - VStack(alignment: .leading, spacing: 8) { - Text(source.displayName) - .font(.largeTitle.weight(.semibold)) - - Text(sourceSummary) - .font(.subheadline) - .foregroundStyle(.secondary) - } + Text(source.displayName) + .font(.largeTitle.weight(.semibold)) if showsStatusSection { sourceStatusSection @@ -47,15 +41,6 @@ struct SourceDetailView: View { } } - private var sourceSummary: String { - switch source.origin { - case .localFolder: - return "Local filesystem source" - case .connectedDevice(let device, let container): - return "\(device.name) • \(container.appName)" - } - } - private var showsStatusSection: Bool { if source.isScanning || source.scanError != nil || source.availability != .available { return true @@ -132,25 +117,24 @@ struct SourceDetailView: View { private var sourceStatusSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Status") - .font(.headline) + .appSectionTitleStyle(.section) VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 10) { if SourcePresentation.showsIndeterminateScanActivityIndicator(for: source) { ProgressView() - .controlSize(.small) + .appActivityIndicatorStyle(.small) } else if !source.isScanning { sourceStatusIcon } Text(statusTitle) - .font(.subheadline.weight(.semibold)) + .appTextStyle(.rowTitle) } if let statusDetail, !statusDetail.isEmpty { Text(statusDetail) - .font(.subheadline) - .foregroundStyle(.secondary) + .appTextStyle(.supporting) } if source.isScanning { @@ -164,8 +148,7 @@ struct SourceDetailView: View { } } } - .padding(18) - .appCardSurface(.primaryPanel) + .appDetailSectionCard() } } @@ -378,13 +361,12 @@ struct SourceDetailView: View { .frame(width: 14) Text(stage.title) - .font(.subheadline.weight(.semibold)) + .appTextStyle(.rowTitle) Spacer() Text(stage.detail) - .font(.caption) - .foregroundStyle(.secondary) + .appTextStyle(.fieldLabel) } if stage.status == .completed { @@ -392,7 +374,7 @@ struct SourceDetailView: View { } else if stage.showsIndeterminateProgress { ProgressView() .progressViewStyle(.linear) - .controlSize(.small) + .appActivityIndicatorStyle(.small) .tint(Color.appAccent) } else if let progress = stage.progress { ProgressView(value: progress, total: 1) @@ -502,14 +484,13 @@ struct SourceDetailView: View { private func sourceSection(title: String, rows: [(String, String)]) -> some View { VStack(alignment: .leading, spacing: 12) { Text(title) - .font(.headline) + .appSectionTitleStyle(.section) VStack(alignment: .leading, spacing: 12) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in HStack(alignment: .top, spacing: 16) { Text(row.0) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) + .appTextStyle(.emphasisLabel) .frame(width: 170, alignment: .leading) Text(row.1) @@ -518,8 +499,7 @@ struct SourceDetailView: View { } } } - .padding(18) - .appCardSurface(.primaryPanel) + .appDetailSectionCard() } } } diff --git a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift index 721a67e..036e868 100644 --- a/World Manager for Minecraft/UI/List/ItemListColumnViews.swift +++ b/World Manager for Minecraft/UI/List/ItemListColumnViews.swift @@ -1,7 +1,7 @@ import SwiftUI import UniformTypeIdentifiers -enum ItemSortMode: String, CaseIterable, Identifiable { +enum ItemSortMode: String, CaseIterable, Identifiable, Hashable, Sendable { case name case modifiedDate case size @@ -33,11 +33,11 @@ struct ItemListColumnView: View { let subtitle: String let showsSubtitle: Bool let isRefreshing: Bool + let isUpdatingProjection: Bool let items: [MinecraftContentItem] let searchPrompt: String let chooseFolderAction: () -> Void let dropAction: ([NSItemProvider]) -> Bool - let dragProvider: (MinecraftContentItem) -> NSItemProvider let itemContextMenu: (MinecraftContentItem) -> MenuContent var body: some View { @@ -50,13 +50,18 @@ struct ItemListColumnView: View { .onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction) } else { List(items, selection: $selectedItemID) { item in - ContentRowView(item: item, dragProvider: dragProvider) + ContentRowView(item: item) .tag(item.id) .contextMenu { itemContextMenu(item) } } .listStyle(.inset) + .overlay { + if isUpdatingProjection && items.isEmpty { + ItemListLoadingOverlay() + } + } } } .safeAreaInset(edge: .top, spacing: 0) { @@ -67,7 +72,8 @@ struct ItemListColumnView: View { title: title, subtitle: subtitle, showsSubtitle: showsSubtitle, - isRefreshing: isRefreshing + isRefreshing: isRefreshing, + isUpdatingProjection: isUpdatingProjection ) } } @@ -100,13 +106,13 @@ private struct ItemListHeaderView: View { let subtitle: String let showsSubtitle: Bool let isRefreshing: Bool + let isUpdatingProjection: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { if showsSourceName { Text(sourceName) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + .appSectionTitleStyle(.overline) .textCase(.uppercase) } @@ -115,47 +121,61 @@ private struct ItemListHeaderView: View { .font(.title2.weight(.semibold)) .lineLimit(2) - if isRefreshing { + if isRefreshing || isUpdatingProjection { ProgressView() - .controlSize(.small) + .appActivityIndicatorStyle(.small) } } - if showsSubtitle { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) + if showsSubtitle || isUpdatingProjection { + Text(displaySubtitle) + .appTextStyle(.supporting) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 12) - .background(.regularMaterial) - .overlay(alignment: .bottom) { - Divider() + .appListHeaderSurface() + } + + private var displaySubtitle: String { + if isUpdatingProjection { + return "Loading items..." } + + return subtitle + } +} + +private struct ItemListLoadingOverlay: View { + var body: some View { + VStack(spacing: 10) { + ProgressView() + .appActivityIndicatorStyle(.small) + + Text("Loading items...") + .appTextStyle(.supporting) + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } } private struct ContentRowView: View { let item: MinecraftContentItem - let dragProvider: (MinecraftContentItem) -> NSItemProvider var body: some View { HStack(alignment: .center, spacing: 10) { ItemThumbnailView(iconURL: item.iconURL) - .onDrag { - dragProvider(item) - } VStack(alignment: .leading, spacing: 4) { Text(item.displayName) .lineLimit(1) Text(metadataLine) - .font(.caption) - .foregroundStyle(.secondary) + .appTextStyle(.fieldLabel) .lineLimit(1) } @@ -163,16 +183,8 @@ private struct ContentRowView: View { if !item.metadataLoaded || !item.sizeLoaded { ProgressView() - .controlSize(.small) + .appActivityIndicatorStyle(.small) } - - Image(systemName: "square.and.arrow.up") - .font(.caption) - .foregroundStyle(.tertiary) - .help("Drag Out as Minecraft Package") - .onDrag { - dragProvider(item) - } } .padding(.vertical, 2) .contentShape(Rectangle()) diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index 421f19f..d3c76b5 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -138,6 +138,9 @@ enum PreviewFixtures { behaviorPackItem, resourcePackItem ] + source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in + counts[item.contentType, default: 0] += 1 + } source.rawItems = source.displayItems source.logicalPacks = [ LogicalPack( @@ -220,6 +223,9 @@ enum PreviewFixtures { var source = MinecraftSource(folderURL: sourceTwoURL) source.displayName = "Downloads" source.displayItems = [secondLibraryPack] + source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in + counts[item.contentType, default: 0] += 1 + } source.rawItems = source.displayItems source.indexedItemCount = source.displayItems.count source.indexedDetailCount = source.displayItems.count @@ -317,11 +323,11 @@ struct ItemListColumnPreviewContainer: View { subtitle: "5 items in Kid iPad Imports", showsSubtitle: false, isRefreshing: false, + isUpdatingProjection: false, items: PreviewFixtures.primarySource.displayItems, searchPrompt: "Search Worlds", chooseFolderAction: {}, dropAction: { _ in false }, - dragProvider: { _ in NSItemProvider() }, itemContextMenu: { item in Button("Reveal \(item.displayName)") {} } diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index 4bf7e80..eae664f 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -20,6 +20,14 @@ struct ContentView: View { @State private var isShowingDeviceSourceSheet = false @State private var sortMode: ItemSortMode = .name @State private var directoryPreviewContents: [DirectoryEntry] = [] + @State private var itemListProjection = ItemCollectionProjection.placeholder( + for: ItemCollectionProjectionRequest( + selection: nil, + searchText: "", + sortMode: .name, + source: nil + ) + ) private let connectedDeviceAccess: AppleMobileDeviceSourceAccess private let deviceSourceFactory: ConnectedDeviceSourceFactory @@ -37,11 +45,29 @@ struct ContentView: View { } var body: some View { + let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty + let resolvedCurrentSource = currentSource + let currentProjectionRequest = ItemCollectionProjectionRequest( + selection: selectedSidebarSelection, + searchText: searchText, + sortMode: sortMode, + source: resolvedCurrentSource + ) + let reusesCurrentProjectedItems = + itemListProjection.request.selection == currentProjectionRequest.selection && + itemListProjection.request.source?.id == currentProjectionRequest.source?.id + let isUpdatingItemListProjection = itemListProjection.request != currentProjectionRequest + let resolvedItemListProjection = itemListProjection.request == currentProjectionRequest + ? itemListProjection + : ItemCollectionProjection.placeholder(for: currentProjectionRequest) + let resolvedCurrentSelectedItem = currentSelectedItem(in: resolvedCurrentSource) + let resolvedDisplayedItems = reusesCurrentProjectedItems ? itemListProjection.items : [] + NavigationSplitView(columnVisibility: $columnVisibility) { SourcesSidebarView( sources: library.sidebarSources, connectedDevices: library.connectedDevices, - selection: $selectedSidebarSelection, + selection: sidebarSelectionBinding, addSourceAction: pickFolder, addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, addConnectedDeviceAction: addConnectedDeviceSource(from:), @@ -58,58 +84,58 @@ struct ContentView: View { .navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380) } content: { ItemListColumnView( - isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, + isEmpty: isEmptyLibrary, isDropTargeted: $isDropTargeted, selectedItemID: $selectedItemID, searchText: $searchText, sortMode: $sortMode, showsHeader: shouldShowItemListHeader, - sourceName: currentSourceDisplayName, + sourceName: resolvedItemListProjection.sourceName, showsSourceName: !isSidebarVisible, - title: collectionHeaderTitle, - subtitle: collectionHeaderSubtitle, - showsSubtitle: isSearching, - isRefreshing: currentSource?.isScanning == true, - items: displayedItems, - searchPrompt: searchPrompt, + title: resolvedItemListProjection.title, + subtitle: resolvedItemListProjection.subtitle, + showsSubtitle: isSearching || isUpdatingItemListProjection, + isRefreshing: resolvedCurrentSource?.isScanning == true, + isUpdatingProjection: isUpdatingItemListProjection, + items: resolvedDisplayedItems, + searchPrompt: resolvedItemListProjection.searchPrompt, chooseFolderAction: pickFolder, dropAction: handleDroppedProviders(_:), - dragProvider: dragProvider(for:), itemContextMenu: itemContextMenu(for:) ) .navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460) } detail: { ItemDetailColumnView( - item: currentSelectedItem, - source: currentSource, - showsSourceDetails: currentSelectedItem == nil && isSourceOverviewSelection, - behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], - resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], - worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [], - backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [], - isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false, + item: resolvedCurrentSelectedItem, + source: resolvedCurrentSource, + showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection, + behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], + resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], + worldsUsingPack: resolvedCurrentSelectedItem.map(worldsUsingPack(for:)) ?? [], + backingPackInstances: resolvedCurrentSelectedItem.map(backingPackInstances(for:)) ?? [], + isSuspiciousPack: resolvedCurrentSelectedItem.map(isSuspiciousPack(_:)) ?? false, contents: directoryPreviewContents, directoryPreviewLimit: directoryPreviewLimit, - isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, + isEmpty: isEmptyLibrary, isPerformingItemAction: isPerformingItemAction, areFileActionsEnabled: areCurrentItemFileActionsEnabled, - exportTitle: currentSelectedItem.map(primaryActionTitle(for:)), + exportTitle: resolvedCurrentSelectedItem.map(primaryActionTitle(for:)), exportAction: { - guard let item = currentSelectedItem else { + guard let item = resolvedCurrentSelectedItem else { return } saveItem(item) }, revealAction: { - guard let item = currentSelectedItem else { + guard let item = resolvedCurrentSelectedItem else { return } revealInFinder(item) }, shareAction: { anchorView in - guard let item = currentSelectedItem else { + guard let item = resolvedCurrentSelectedItem else { return } @@ -119,7 +145,7 @@ struct ContentView: View { .frame(minWidth: 450) } .overlay { - if library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty { + if library.isRestoringPersistedSources && isEmptyLibrary { LaunchRestoreOverlayView() } } @@ -138,60 +164,46 @@ struct ContentView: View { .task { AppTerminationCoordinator.shared.register(library: library) } - .disabled(library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty) - .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in + .disabled(library.isRestoringPersistedSources && isEmptyLibrary) + .onChange(of: resolvedDisplayedItems.map(\.id)) { _, filteredIDs in guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { return } self.selectedItemID = nil } - .onChange(of: selectedSidebarSelection) { _, selection in - guard let selection else { - return - } - - if case .source = selection { - selectedItemID = nil - } - } .onChange(of: library.sources.map(\.id)) { _, _ in syncSelection(with: library.visibleSources.map(\.id)) } .onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in syncSelection(with: library.visibleSources.map(\.id)) } - .task(id: currentSelectedItem?.id) { + .task(id: currentProjectionRequest) { + let request = currentProjectionRequest + let projection = await Task.detached(priority: .userInitiated) { + ItemCollectionProjector.makeProjection(for: request) + }.value + guard !Task.isCancelled else { + return + } + itemListProjection = projection + } + .task(id: resolvedCurrentSelectedItem?.id) { await refreshDirectoryPreviewContents() } } - private var scopedItems: [MinecraftContentItem] { - guard let selectedSidebarSelection else { - return [] - } + private var sidebarSelectionBinding: Binding { + Binding( + get: { selectedSidebarSelection }, + set: { newSelection in + if newSelection != selectedSidebarSelection { + selectedItemID = nil + } - switch selectedSidebarSelection { - case .source(let sourceID), .allContent(let sourceID): - return library.source(withID: sourceID)?.items ?? [] - case .contentType(let sourceID, let contentType): - return library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? [] - } - } - - private var filteredItems: [MinecraftContentItem] { - let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedSearchText.isEmpty else { - return scopedItems - } - - return scopedItems.filter { item in - item.searchText.localizedCaseInsensitiveContains(trimmedSearchText) - } - } - - private var displayedItems: [MinecraftContentItem] { - filteredItems.sorted(by: sortComparator) + selectedSidebarSelection = newSelection + } + ) } private var currentSource: MinecraftSource? { @@ -202,14 +214,23 @@ struct ContentView: View { return library.source(withID: sourceID) } - private var currentSelectedItem: MinecraftContentItem? { + private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? { guard let selectedItemID else { return nil } - return library.visibleSources - .flatMap(\.items) - .first(where: { $0.id == selectedItemID }) + if let source, + let item = source.items.first(where: { $0.id == selectedItemID }) { + return item + } + + for source in library.visibleSources { + if let item = source.items.first(where: { $0.id == selectedItemID }) { + return item + } + } + + return nil } private var sortComparator: (MinecraftContentItem, MinecraftContentItem) -> Bool { @@ -255,84 +276,14 @@ struct ContentView: View { } } - private var collectionHeaderTitle: String { - if isSearching { - return "Searching “\(searchScopeTitle)”" - } - - guard let selectedSidebarSelection else { - return "Library" - } - - switch selectedSidebarSelection { - case .source, .allContent: - return "All Items" - case .contentType(_, let contentType): - return sidebarTitle(for: contentType) - } - } - - private var collectionHeaderSubtitle: String { - let totalCount = scopedItems.count - let filteredCount = filteredItems.count - let noun = collectionCountNoun - - if !isSearching { - return "\(totalCount.formatted(.number)) \(noun)" - } - - return "\(filteredCount.formatted(.number)) of \(totalCount.formatted(.number)) \(noun)" - } - - private var currentSourceDisplayName: String { - currentSource?.displayName ?? "Library" - } - - private var currentCollectionStatus: String? { - guard let currentSource else { - return nil - } - - if currentSource.isScanning { - return currentSource.scanStatus - } - - if let scanError = currentSource.scanError, !scanError.isEmpty { - return scanError - } - - if !currentSource.scanStatus.isEmpty { - return currentSource.scanStatus - } - - if let lastScanDate = currentSource.lastScanDate { - return "Last scanned \(lastScanDate.formatted(date: .abbreviated, time: .shortened))" - } - - return nil - } - private var areCurrentItemFileActionsEnabled: Bool { - guard currentSelectedItem != nil else { + guard currentSelectedItem(in: currentSource) != nil else { return false } return currentSource?.availability == .available } - private var searchScopeTitle: String { - switch selectedSidebarSelection { - case .some(.source(let sourceID)): - return library.source(withID: sourceID)?.displayName ?? "Library" - case .some(.allContent): - return "All" - case .some(.contentType(_, let contentType)): - return sidebarTitle(for: contentType) - case .none: - return "Library" - } - } - private var isSearching: Bool { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -345,42 +296,9 @@ struct ContentView: View { isSearching || !isSidebarVisible } - private var collectionCountNoun: String { - guard let selectedSidebarSelection else { - return "items" - } - - switch selectedSidebarSelection { - case .source, .allContent: - return scopedItems.count == 1 ? "item" : "items" - case .contentType(_, let contentType): - switch contentType { - case .world: - return scopedItems.count == 1 ? "world" : "worlds" - case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: - return scopedItems.count == 1 ? "pack" : "packs" - } - } - } - - private var searchPrompt: String { - switch selectedSidebarSelection { - case .some(.source(let sourceID)): - let sourceName = library.source(withID: sourceID)?.displayName ?? "Library" - return "Search \(sourceName)" - case .some(.allContent): - return "Search All Items" - case .some(.contentType(_, let contentType)): - return "Search \(sidebarTitle(for: contentType))" - case .none: - return "Search Library" - } - } - private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] { - MinecraftContentType.allCases.compactMap { contentType in - let count = source.items.filter { $0.contentType == contentType }.count - guard count > 0 else { + return MinecraftContentType.allCases.compactMap { contentType in + guard let count = source.displayItemCountsByType[contentType], count > 0 else { return nil } @@ -539,7 +457,8 @@ struct ContentView: View { } private func refreshDirectoryPreviewContents() async { - guard let item = currentSelectedItem, let source = currentSource else { + guard let source = currentSource, + let item = currentSelectedItem(in: source) else { await MainActor.run { directoryPreviewContents = [] } @@ -615,7 +534,7 @@ struct ContentView: View { selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) } } - if let selectedItemID, currentSelectedItem?.id != selectedItemID { + if let selectedItemID, currentSelectedItem(in: currentSource)?.id != selectedItemID { self.selectedItemID = nil } } diff --git a/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift new file mode 100644 index 0000000..d3ea807 --- /dev/null +++ b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift @@ -0,0 +1,189 @@ +import Foundation + +struct ItemCollectionProjectionRequest: Hashable, Sendable { + let selection: SidebarSelection? + let searchText: String + let sortMode: ItemSortMode + let source: MinecraftSource? +} + +struct ItemCollectionProjection: Sendable { + let request: ItemCollectionProjectionRequest + let sourceName: String + let title: String + let subtitle: String + let searchPrompt: String + let items: [MinecraftContentItem] + + nonisolated static func placeholder(for request: ItemCollectionProjectionRequest) -> ItemCollectionProjection { + ItemCollectionProjection( + request: request, + sourceName: request.source?.displayName ?? "Library", + title: ItemCollectionProjector.title( + for: request.selection, + isSearching: !ItemCollectionProjector.trimmedSearchText(for: request).isEmpty + ), + subtitle: "", + searchPrompt: ItemCollectionProjector.searchPrompt(for: request.selection, source: request.source), + items: [] + ) + } +} + +enum ItemCollectionProjector { + nonisolated static func makeProjection(for request: ItemCollectionProjectionRequest) -> ItemCollectionProjection { + let trimmedSearchText = trimmedSearchText(for: request) + let scopedItems = request.source?.items(matching: request.selection) ?? [] + let filteredItems: [MinecraftContentItem] + + if trimmedSearchText.isEmpty { + filteredItems = scopedItems + } else { + filteredItems = scopedItems.filter { item in + item.searchText.localizedCaseInsensitiveContains(trimmedSearchText) + } + } + + let displayedItems = filteredItems.sorted(by: sortComparator(for: request.sortMode)) + let countNoun = collectionCountNoun(for: request.selection, scopedItemCount: scopedItems.count) + let subtitle: String + + if trimmedSearchText.isEmpty { + subtitle = "\(scopedItems.count.formatted(.number)) \(countNoun)" + } else { + subtitle = "\(displayedItems.count.formatted(.number)) of \(scopedItems.count.formatted(.number)) \(countNoun)" + } + + return ItemCollectionProjection( + request: request, + sourceName: request.source?.displayName ?? "Library", + title: title(for: request.selection, isSearching: !trimmedSearchText.isEmpty), + subtitle: subtitle, + searchPrompt: searchPrompt(for: request.selection, source: request.source), + items: displayedItems + ) + } + + nonisolated static func title(for selection: SidebarSelection?, isSearching: Bool) -> String { + if isSearching { + return "Searching “\(searchScopeTitle(for: selection))”" + } + + guard let selection else { + return "Library" + } + + switch selection { + case .source, .allContent: + return "All Items" + case .contentType(_, let contentType): + return sidebarTitle(for: contentType) + } + } + + nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String { + switch selection { + case .some(.source): + return "Search \(source?.displayName ?? "Library")" + case .some(.allContent): + return "Search All Items" + case .some(.contentType(_, let contentType)): + return "Search \(sidebarTitle(for: contentType))" + case .none: + return "Search Library" + } + } + + nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String { + switch selection { + case .some(.source): + return "Library" + case .some(.allContent): + return "All" + case .some(.contentType(_, let contentType)): + return sidebarTitle(for: contentType) + case .none: + return "Library" + } + } + + nonisolated private static func collectionCountNoun(for selection: SidebarSelection?, scopedItemCount: Int) -> String { + guard let selection else { + return "items" + } + + switch selection { + case .source, .allContent: + return scopedItemCount == 1 ? "item" : "items" + case .contentType(_, let contentType): + switch contentType { + case .world: + return scopedItemCount == 1 ? "world" : "worlds" + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + return scopedItemCount == 1 ? "pack" : "packs" + } + } + } + + nonisolated private static func sidebarTitle(for contentType: MinecraftContentType) -> String { + switch contentType { + case .world: + return "Worlds" + case .behaviorPack: + return "Behavior Packs" + case .resourcePack: + return "Resource Packs" + case .skinPack: + return "Skin Packs" + case .worldTemplate: + return "World Templates" + } + } + + nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String { + request.searchText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + nonisolated private static func sortComparator(for mode: ItemSortMode) -> (MinecraftContentItem, MinecraftContentItem) -> Bool { + switch mode { + case .name: + return { lhs, rhs in + lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + case .modifiedDate: + return { lhs, rhs in + switch (lhs.displayDate, rhs.displayDate) { + case let (lhsDate?, rhsDate?): + if lhsDate != rhsDate { + return lhsDate > rhsDate + } + case (.some, nil): + return true + case (nil, .some): + return false + case (nil, nil): + break + } + + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + case .size: + return { lhs, rhs in + switch (lhs.sizeBytes, rhs.sizeBytes) { + case let (lhsSize?, rhsSize?): + if lhsSize != rhsSize { + return lhsSize > rhsSize + } + case (.some, nil): + return true + case (nil, .some): + return false + case (nil, nil): + break + } + + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + } + } +} diff --git a/World Manager for Minecraft/UI/Shared/ContentUIShared.swift b/World Manager for Minecraft/UI/Shared/ContentUIShared.swift index 25c9f12..5cc4983 100644 --- a/World Manager for Minecraft/UI/Shared/ContentUIShared.swift +++ b/World Manager for Minecraft/UI/Shared/ContentUIShared.swift @@ -2,19 +2,18 @@ import AppKit import SwiftUI enum AppChrome { - static let sidebarRowCornerRadius: CGFloat = 10 static let placeholderCardCornerRadius: CGFloat = 16 static let panelCardCornerRadius: CGFloat = 18 + static let detailSectionCardPadding: CGFloat = 18 } enum AppCardStyle { - case primaryPanel - case secondaryPanel + case detailPanel case placeholder fileprivate var cornerRadius: CGFloat { switch self { - case .primaryPanel, .secondaryPanel: + case .detailPanel: return AppChrome.panelCardCornerRadius case .placeholder: return AppChrome.placeholderCardCornerRadius @@ -23,9 +22,7 @@ enum AppCardStyle { fileprivate var fillStyle: AnyShapeStyle { switch self { - case .primaryPanel: - return AnyShapeStyle(.regularMaterial) - case .secondaryPanel: + case .detailPanel: return AnyShapeStyle(.quaternary.opacity(0.32)) case .placeholder: return AnyShapeStyle(.thinMaterial) @@ -44,19 +41,213 @@ private struct AppCardSurfaceModifier: ViewModifier { } } +enum AppSectionTitleStyle { + case section + case overline +} + +enum AppTextStyle { + case rowTitle + case supporting + case supportingCompact + case fieldLabel + case emphasisLabel +} + +enum AppActivityIndicatorStyle { + case small + case large +} + +private struct AppSectionTitleModifier: ViewModifier { + let style: AppSectionTitleStyle + + func body(content: Content) -> some View { + switch style { + case .section: + content + .font(.headline) + case .overline: + content + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .tracking(0.5) + } + } +} + +private struct AppTextStyleModifier: ViewModifier { + let style: AppTextStyle + + func body(content: Content) -> some View { + switch style { + case .rowTitle: + content + .font(.subheadline.weight(.semibold)) + case .supporting: + content + .font(.subheadline) + .foregroundStyle(.secondary) + case .supportingCompact: + content + .font(.footnote) + .foregroundStyle(.secondary) + case .fieldLabel: + content + .font(.caption) + .foregroundStyle(.secondary) + case .emphasisLabel: + content + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + } +} + +private struct AppDetailSectionCardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppChrome.detailSectionCardPadding) + .appCardSurface(.detailPanel) + } +} + +private struct AppActivityIndicatorModifier: ViewModifier { + let style: AppActivityIndicatorStyle + + func body(content: Content) -> some View { + switch style { + case .small: + content.controlSize(.small) + case .large: + content.controlSize(.large) + } + } +} + +private struct AppListHeaderSurfaceModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Divider() + } + } +} + +private struct AppMiniProminentButtonModifier: ViewModifier { + func body(content: Content) -> some View { + content + .buttonStyle(.borderedProminent) + .controlSize(.small) + } +} + +private struct AppTransportBadgeBubbleModifier: ViewModifier { + func body(content: Content) -> some View { + content + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.primary) + .padding(4) + .background(.thinMaterial, in: Circle()) + } +} + +enum AppCapsuleLabelStyle { + case sidebarSubtle + case sidebarAccent + case heroMetadata +} + +private struct AppCapsuleLabelModifier: ViewModifier { + let style: AppCapsuleLabelStyle + + func body(content: Content) -> some View { + content + .font(.caption.weight(.semibold)) + .foregroundStyle(foregroundStyle) + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .background(backgroundStyle, in: Capsule()) + } + + private var foregroundStyle: AnyShapeStyle { + switch style { + case .sidebarSubtle: + return AnyShapeStyle(.secondary) + case .sidebarAccent: + return AnyShapeStyle(Color.appAccent) + case .heroMetadata: + return AnyShapeStyle(.white.opacity(0.95)) + } + } + + private var backgroundStyle: AnyShapeStyle { + switch style { + case .sidebarSubtle: + return AnyShapeStyle(.secondary.opacity(0.12)) + case .sidebarAccent: + return AnyShapeStyle(Color.appAccent.opacity(0.14)) + case .heroMetadata: + return AnyShapeStyle(.white.opacity(0.14)) + } + } + + private var horizontalPadding: CGFloat { + switch style { + case .heroMetadata: + return 10 + case .sidebarSubtle, .sidebarAccent: + return 7 + } + } + + private var verticalPadding: CGFloat { + switch style { + case .heroMetadata: + return 7 + case .sidebarSubtle, .sidebarAccent: + return 4 + } + } +} + extension View { func appCardSurface(_ style: AppCardStyle) -> some View { modifier(AppCardSurfaceModifier(style: style)) } - func appSidebarRowSurface(isHighlighted: Bool) -> some View { - let fillStyle = isHighlighted - ? AnyShapeStyle(.secondary.opacity(0.08)) - : AnyShapeStyle(Color.clear) - return background( - fillStyle, - in: RoundedRectangle(cornerRadius: AppChrome.sidebarRowCornerRadius, style: .continuous) - ) + func appSectionTitleStyle(_ style: AppSectionTitleStyle) -> some View { + modifier(AppSectionTitleModifier(style: style)) + } + + func appTextStyle(_ style: AppTextStyle) -> some View { + modifier(AppTextStyleModifier(style: style)) + } + + func appActivityIndicatorStyle(_ style: AppActivityIndicatorStyle) -> some View { + modifier(AppActivityIndicatorModifier(style: style)) + } + + func appDetailSectionCard() -> some View { + modifier(AppDetailSectionCardModifier()) + } + + func appCapsuleLabelStyle(_ style: AppCapsuleLabelStyle) -> some View { + modifier(AppCapsuleLabelModifier(style: style)) + } + + func appListHeaderSurface() -> some View { + modifier(AppListHeaderSurfaceModifier()) + } + + func appMiniProminentButton() -> some View { + modifier(AppMiniProminentButtonModifier()) + } + + func appTransportBadgeBubble() -> some View { + modifier(AppTransportBadgeBubbleModifier()) } } @@ -297,11 +488,7 @@ struct RecordHeroView: View { private var recordHeroChips: some View { FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in Text(chip) - .font(.caption.weight(.semibold)) - .foregroundStyle(.white.opacity(0.95)) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(.white.opacity(0.14), in: Capsule()) + .appCapsuleLabelStyle(.heroMetadata) } } @@ -391,7 +578,7 @@ struct LaunchRestoreOverlayView: View { VStack(spacing: 14) { ProgressView() - .controlSize(.large) + .appActivityIndicatorStyle(.large) Text("Opening Library…") .font(.title3.weight(.semibold)) diff --git a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift index 200bf91..b4825b2 100644 --- a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift +++ b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift @@ -1,6 +1,6 @@ import SwiftUI -enum SidebarSelection: Hashable { +enum SidebarSelection: Hashable, Sendable { case source(sourceID: URL) case allContent(sourceID: URL) case contentType(sourceID: URL, contentType: MinecraftContentType) @@ -78,14 +78,13 @@ struct SourcesSidebarView: View { SourceHeaderRow( source: source, - isSelected: selection == .source(sourceID: source.id), onSelect: { selection = .source(sourceID: source.id) } ) .tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 8)) + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 0)) .contextMenu { Button("Rescan \"\(source.displayName)\"") { rescanSourceAction(source) @@ -143,54 +142,49 @@ private struct SidebarSourcesSectionHeaderView: View { var body: some View { Text(title) - .font(.headline) - .foregroundStyle(.secondary) - .textCase(nil) } } private struct SourceHeaderRow: View { let source: MinecraftSource - let isSelected: Bool let onSelect: () -> Void - @State private var isHovering = false var body: some View { - Button(action: onSelect) { + HStack(spacing: 8) { + Image(systemName: headerSymbolName) + .foregroundStyle(.secondary) + + Text(source.displayName) + .lineLimit(1) + + Spacer(minLength: 8) + HStack(spacing: 8) { - Image(systemName: headerSymbolName) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(titleColor) - - Text(source.displayName) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(titleColor) - - Spacer(minLength: 8) + if let availabilityBadgeText { + SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis) + } if let connection { SourceConnectionBadge(connection: connection) } - if let availabilityBadgeText { - SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis) - } - - if showsStatusIndicator { - statusIndicator - .frame(width: 24, height: 24) + if showsStatusAccessory { + statusAccessory } } } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .appSidebarRowSurface(isHighlighted: isHovering && !isSelected) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 5) + .padding(.vertical, 6) .contentShape(Rectangle()) - .onHover { isHovering = $0 } + .onTapGesture(perform: onSelect) } private var connection: DeviceConnection? { + guard source.availability == .available else { + return nil + } + guard case .connectedDevice(let device, _) = source.origin else { return nil } @@ -207,14 +201,6 @@ private struct SourceHeaderRow: View { } } - private var titleColor: Color { - if source.availability != .available && !isSelected { - return .secondary - } - - return isSelected ? Color.primary : .secondary - } - private var availabilityBadgeText: String? { if source.isOfflineCached { return "Cached" @@ -233,33 +219,22 @@ private struct SourceHeaderRow: View { private var availabilityBadgeEmphasis: Bool { source.availability == .limited } - @ViewBuilder - private var statusIndicator: some View { - if source.isScanning { - if let scanProgress = source.scanProgress { - CircularScanProgressView(progress: scanProgress) - } else { - ProgressView() - .controlSize(.small) - } - } else if source.availability == .limited { - Image(systemName: "lock.circle") - .foregroundStyle(.secondary) - } else if source.availability != .available { - Image(systemName: source.isOfflineCached ? "externaldrive.badge.exclamationmark" : "slash.circle") - .foregroundStyle(.secondary) - } else if source.scanError != nil { - Image(systemName: "exclamationmark.circle") - .foregroundStyle(.secondary) - } else { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - } + + private var showsStatusAccessory: Bool { + source.isScanning } - private var showsStatusIndicator: Bool { - source.isScanning || source.scanError != nil || source.availability != .available - } + @ViewBuilder + private var statusAccessory: some View { + if source.isScanning { + if let scanProgress = source.scanProgress { + CircularScanProgressView(progress: scanProgress) + } else { + ProgressView() + .appActivityIndicatorStyle(.small) + } + } + } } private struct SourceConnectionBadge: View { @@ -267,11 +242,7 @@ private struct SourceConnectionBadge: View { var body: some View { Image(systemName: symbolName) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background(.secondary.opacity(0.12), in: Capsule()) + .appCapsuleLabelStyle(.sidebarSubtle) .help(helpText) .accessibilityLabel(helpText) } @@ -301,15 +272,7 @@ private struct SourceAvailabilityBadge: View { var body: some View { Text(text) - .font(.caption.weight(.semibold)) - .foregroundStyle(emphasis ? Color.appAccent : .secondary) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .background(backgroundColor, in: Capsule()) - } - - private var backgroundColor: Color { - emphasis ? Color.appAccent.opacity(0.14) : .secondary.opacity(0.12) + .appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle) } } @@ -350,12 +313,11 @@ private struct ConnectedDeviceRow: View { VStack(alignment: .leading, spacing: 4) { Text(entry.device.name) - .font(.subheadline.weight(.semibold)) + .appTextStyle(.rowTitle) .foregroundStyle(titleColor) Text(statusText) - .font(.footnote) - .foregroundStyle(.secondary) + .appTextStyle(.supportingCompact) } Spacer(minLength: 12) @@ -364,8 +326,7 @@ private struct ConnectedDeviceRow: View { Button("Add") { addAction() } - .buttonStyle(.borderedProminent) - .controlSize(.small) + .appMiniProminentButton() } } .opacity(addAction == nil ? 0.68 : 1) @@ -429,10 +390,7 @@ private struct ConnectedDeviceTransportIcon: View { .frame(width: 28, height: 28) Image(systemName: badgeSymbolName) - .font(.system(size: 8, weight: .bold)) - .foregroundStyle(.primary) - .padding(4) - .background(.thinMaterial, in: Circle()) + .appTransportBadgeBubble() .offset(x: 4, y: 4) } .help(helpText)