UI and behavior changes

This commit is contained in:
John Burwell 2026-05-25 16:57:49 -05:00
parent 2932ac2f48
commit 6d2ee05786
8 changed files with 277 additions and 109 deletions

View File

@ -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)",

View File

@ -1,6 +1,15 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.240",
"green" : "0.630",
"red" : "0.360"
}
},
"idiom" : "universal"
}
],

View File

@ -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,6 +944,9 @@ private struct ItemDetailView: View {
.font(.subheadline.weight(.semibold))
ForEach(packs) { pack in
HStack(alignment: .top, spacing: 12) {
PackReferenceIconView(iconURL: pack.iconURL)
VStack(alignment: .leading, spacing: 2) {
Text(pack.name)
@ -920,6 +959,7 @@ private struct ItemDetailView: View {
}
}
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
@ -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 {

View File

@ -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

View File

@ -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
}
}

View File

@ -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)
}
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { 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)
}
}
continuation.onTermination = { @Sendable _ in
discoveryTask.cancel()
}
}
var discoveredCount = 0
for try await item in discoveryStream {
guard !Task.isCancelled else {
break
}
discoveredCount += 1
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..."
source.items.append(item)
source.indexedItemCount = discoveredCount
source.scanStatus = "Found \(discoveredCount) items. Loading details..."
}
refreshSidebarFooterState()
var loadedCount = 0
await enrichmentTracker.beginEnrichment()
let tracker = enrichmentTracker
await withTaskGroup(of: MinecraftContentItem.self) { group in
for item in discoveredItems {
group.addTask {
WorldScanner.enrich(item: item)
Task.detached(priority: .utility) {
let enrichedItem = WorldScanner.enrich(item: item)
await applyEnrichedItem(enrichedItem)
await tracker.finishEnrichment()
}
}
for await enrichedItem in group {
guard !Task.isCancelled else {
return
}
await enrichmentTracker.markDiscoveryFinished()
await enrichmentTracker.waitForCompletion()
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()
}
}
if discoveredItems.isEmpty {
updateSource(sourceID) { source in
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<Void, Never>?
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
}
}

View File

@ -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(
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

View File

@ -12,6 +12,8 @@ struct World_Manager_for_MinecraftApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.tint(Color("AccentColor"))
}
.windowToolbarStyle(.unifiedCompact(showsTitle: false))
}
}