world-manager/World Manager for Minecraft/ContentView.swift

370 lines
11 KiB
Swift

//
// ContentView.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import AppKit
import SwiftUI
struct ContentView: View {
@StateObject private var scanner = WorldScanner()
@State private var folderURL: URL?
@State private var selectedItem: MinecraftContentItem?
@State private var selectedSidebarSelection: SidebarSelection = .all
var body: some View {
NavigationSplitView {
VStack(alignment: .leading, spacing: 12) {
Button("Choose Minecraft Folder...") {
pickFolder()
}
if let folderURL {
Text(folderURL.path)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else {
Text("No folder selected")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let scanError = scanner.scanError {
Text(scanError)
.font(.footnote)
.foregroundStyle(.red)
}
List(selection: $selectedSidebarSelection) {
if let folderURL {
Section(folderURL.lastPathComponent) {
ForEach(sidebarFilters) { filter in
SidebarFilterRow(filter: filter)
.tag(filter.selection)
}
}
}
}
.listStyle(.sidebar)
}
.padding()
.navigationTitle("Source")
} content: {
List(filteredItems, selection: $selectedItem) { item in
HStack(alignment: .top, spacing: 10) {
ItemThumbnailView(iconURL: item.iconURL)
VStack(alignment: .leading, spacing: 4) {
Text(item.displayName)
.lineLimit(1)
Text(item.contentType.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
Text(item.folderName)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Spacer()
if !item.metadataLoaded {
ProgressView()
.controlSize(.small)
}
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.tag(item)
}
.navigationTitle(contentListTitle)
} detail: {
if let selectedItem = currentSelectedItem {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 16) {
LargeItemThumbnailView(iconURL: selectedItem.iconURL)
VStack(alignment: .leading, spacing: 8) {
Text(selectedItem.displayName)
.font(.title2)
Text(selectedItem.contentType.rawValue)
.font(.headline)
.foregroundStyle(.secondary)
Text(selectedItem.folderName)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path)
if let modifiedDate = selectedItem.modifiedDate {
detailRow(
title: "Modified",
value: modifiedDate.formatted(date: .abbreviated, time: .shortened)
)
}
if let sizeBytes = selectedItem.sizeBytes {
detailRow(
title: "Size",
value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
)
}
detailRow(
title: "Metadata",
value: selectedItem.metadataLoaded ? "Loaded" : "Loading..."
)
}
.padding()
}
} else {
Text("Select a world or pack to see details")
.foregroundStyle(.secondary)
}
}
.navigationTitle("Minecraft World Manager")
.toolbar {
ToolbarItem(placement: .primaryAction) {
if scanner.isScanning {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text(scanner.scanStatus)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: 280, alignment: .trailing)
}
}
}
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
guard let selectedItem, !filteredIDs.contains(selectedItem.id) else {
return
}
self.selectedItem = nil
}
}
private var filteredItems: [MinecraftContentItem] {
switch selectedSidebarSelection {
case .all:
return scanner.items
case .contentType(let contentType):
return scanner.items.filter { $0.contentType == contentType }
}
}
private var currentSelectedItem: MinecraftContentItem? {
guard let selectedItem else {
return nil
}
return scanner.items.first(where: { $0.id == selectedItem.id }) ?? selectedItem
}
private var contentListTitle: String {
switch selectedSidebarSelection {
case .all:
return "Minecraft Content"
case .contentType(let contentType):
return contentType.rawValue + "s"
}
}
private var sidebarFilters: [SidebarFilter] {
var filters = [
SidebarFilter(
title: "All Content",
iconName: "square.grid.2x2",
count: scanner.items.count,
selection: .all
)
]
filters.append(
contentsOf: MinecraftContentType.allCases.compactMap { contentType in
let count = scanner.items.filter { $0.contentType == contentType }.count
guard count > 0 else {
return nil
}
return SidebarFilter(
title: sidebarTitle(for: contentType),
iconName: sidebarIcon(for: contentType),
count: count,
selection: .contentType(contentType)
)
}
)
return filters
}
private 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"
}
}
private func sidebarIcon(for contentType: MinecraftContentType) -> String {
switch contentType {
case .world:
return "globe.europe.africa"
case .behaviorPack:
return "shippingbox"
case .resourcePack:
return "paintpalette"
case .skinPack:
return "person.crop.square"
case .worldTemplate:
return "doc.on.doc"
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.textSelection(.enabled)
}
}
private func pickFolder() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.title = "Choose a Folder to Search"
guard panel.runModal() == .OK, let pickedURL = panel.url else {
return
}
folderURL = pickedURL
selectedItem = nil
selectedSidebarSelection = .all
Task {
await scanner.scan(at: pickedURL)
}
}
}
private enum SidebarSelection: Hashable {
case all
case contentType(MinecraftContentType)
}
private struct SidebarFilter: Identifiable, Hashable {
let id = UUID()
let title: String
let iconName: String
let count: Int
let selection: SidebarSelection
}
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)
Spacer()
Text(filter.count, format: .number)
.foregroundStyle(.secondary)
}
}
}
private struct ItemThumbnailView: View {
let iconURL: URL?
var body: some View {
if let image = loadImage(from: iconURL) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 36, height: 36)
.overlay(
Image(systemName: "shippingbox")
.foregroundStyle(.secondary)
)
}
}
}
private struct LargeItemThumbnailView: View {
let iconURL: URL?
var body: some View {
if let image = loadImage(from: iconURL) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
RoundedRectangle(cornerRadius: 12)
.fill(.quaternary)
.frame(width: 128, height: 128)
.overlay(
Image(systemName: "shippingbox")
.font(.largeTitle)
.foregroundStyle(.secondary)
)
}
}
}
private func loadImage(from url: URL?) -> NSImage? {
guard let url else {
return nil
}
return NSImage(contentsOf: url)
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}