world-manager/World Manager for Minecraft/UI/Shared/ContentUIShared.swift

837 lines
25 KiB
Swift

// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import SwiftUI
enum AppChrome {
static let placeholderCardCornerRadius: CGFloat = 16
static let panelCardCornerRadius: CGFloat = 18
static let detailSectionCardPadding: CGFloat = 18
}
enum AppCardStyle {
case detailPanel
case placeholder
fileprivate var cornerRadius: CGFloat {
switch self {
case .detailPanel:
return AppChrome.panelCardCornerRadius
case .placeholder:
return AppChrome.placeholderCardCornerRadius
}
}
fileprivate var fillStyle: AnyShapeStyle {
switch self {
case .detailPanel:
return AnyShapeStyle(.quaternary.opacity(0.32))
case .placeholder:
return AnyShapeStyle(.thinMaterial)
}
}
}
private struct AppCardSurfaceModifier: ViewModifier {
let style: AppCardStyle
func body(content: Content) -> some View {
content.background(
style.fillStyle,
in: RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous)
)
}
}
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 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())
}
}
struct ToolbarShareButton: View {
let systemImage: String
let isEnabled: Bool
let action: (NSView?) -> Void
@State private var anchorView: NSView?
var body: some View {
Button {
action(anchorView)
} label: {
Image(systemName: systemImage)
}
.disabled(!isEnabled)
.background {
ShareAnchorView(anchorView: $anchorView)
}
}
}
struct ActionPillButton: View {
enum Prominence {
case primary
case secondary
}
let title: String
let systemImage: String
var isDisabled = false
var prominence: Prominence = .secondary
let action: () -> Void
var body: some View {
Button(action: action) {
HeroActionLabel(title: title, systemImage: systemImage)
}
.buttonStyle(HeroActionButtonStyle(prominence: prominence))
.disabled(isDisabled)
.opacity(isDisabled ? 0.55 : 1)
}
}
struct SharingPillButton: View {
let title: String
let systemImage: String
let isEnabled: Bool
let action: (NSView?) -> Void
@State private var anchorView: NSView?
var body: some View {
Button {
action(anchorView)
} label: {
HeroActionLabel(title: title, systemImage: systemImage)
}
.buttonStyle(HeroActionButtonStyle(prominence: .secondary))
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.55)
.background {
ShareAnchorView(anchorView: $anchorView)
}
}
}
struct HeroActionLabel: View {
let title: String
let systemImage: String
var body: some View {
Label(title, systemImage: systemImage)
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
}
struct HeroActionButtonStyle: ButtonStyle {
let prominence: ActionPillButton.Prominence
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.background(backgroundColor.opacity(configuration.isPressed ? pressedOpacity : 1), in: Capsule())
.overlay {
if prominence == .secondary {
Capsule()
.strokeBorder(.white.opacity(0.14))
}
}
.controlSize(.large)
}
private var backgroundColor: Color {
switch prominence {
case .primary:
return .appAccent
case .secondary:
return Color.black.opacity(0.22)
}
}
private var pressedOpacity: CGFloat {
switch prominence {
case .primary:
return 0.88
case .secondary:
return 0.72
}
}
}
struct ShareAnchorView: NSViewRepresentable {
@Binding var anchorView: NSView?
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async {
anchorView = view
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if anchorView !== nsView {
DispatchQueue.main.async {
anchorView = nsView
}
}
}
}
struct RecordHeroView: View {
private let heroHeight: CGFloat = 360
private let heroTopPadding: CGFloat = 74
private let heroBottomPadding: CGFloat = 20
private let titleLineLimit = 5
private let titleMinimumScale: CGFloat = 0.78
private let detailsColumnSpacing: CGFloat = 18
let item: MinecraftContentItem
let metadataChips: [String]
let fallbackSystemImage: String
let copyAction: () -> Void
let actionRow: AnyView
let contentMaxWidth: CGFloat
var body: some View {
GeometryReader { proxy in
let innerHeight = max(0, proxy.size.height - heroTopPadding - heroBottomPadding)
ZStack(alignment: .topLeading) {
artworkBackground
LinearGradient(
colors: [.black.opacity(0.12), .black.opacity(0.22), .black.opacity(0.5)],
startPoint: .top,
endPoint: .bottom
)
HStack(alignment: .top, spacing: 18) {
HeroThumbnailView(
iconURL: item.iconURL,
contentType: item.contentType,
availableHeight: innerHeight
)
VStack(alignment: .leading, spacing: detailsColumnSpacing) {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .top, spacing: 10) {
Text(item.displayName)
.font(titleFont)
.foregroundStyle(.white)
.lineLimit(titleLineLimit)
.minimumScaleFactor(titleMinimumScale)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
Button(action: copyAction) {
Image(systemName: "document.on.document")
.font(.title3.weight(.semibold))
.foregroundStyle(.white.opacity(0.88))
}
.buttonStyle(.plain)
}
recordHeroChips
}
Spacer(minLength: 16)
actionRow
}
.frame(maxWidth: .infinity, maxHeight: innerHeight, alignment: .topLeading)
}
.frame(maxWidth: contentMaxWidth, maxHeight: innerHeight, alignment: .topLeading)
.padding(.horizontal, 28)
.padding(.top, heroTopPadding)
.padding(.bottom, heroBottomPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
}
.frame(maxWidth: .infinity)
.frame(height: heroHeight, alignment: .top)
.clipShape(Rectangle())
.ignoresSafeArea(edges: .top)
}
private var artworkBackground: some View {
Group {
if let image = loadImage(from: item.iconURL) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.blur(radius: 36)
.saturation(1.08)
} else {
LinearGradient(
colors: [Color.appAccent.opacity(0.9), Color.black.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
Image(systemName: fallbackSystemImage)
.font(.system(size: 84))
.foregroundStyle(.white.opacity(0.2))
}
}
}
}
private var recordHeroChips: some View {
FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in
Text(chip)
.appCapsuleLabelStyle(.heroMetadata)
}
}
private var titleFont: Font {
.system(
item.displayName.count > 70 ? .title2 : .largeTitle,
design: .rounded
).weight(.semibold)
}
}
private struct HeroThumbnailView: View {
private let thumbnailMaxWidth: CGFloat = 280
private let thumbnailMaxHeight: CGFloat = 180
private let thumbnailMinHeight: CGFloat = 120
let iconURL: URL?
let contentType: MinecraftContentType
let availableHeight: CGFloat
var body: some View {
Group {
if let image = loadImage(from: iconURL) {
let frame = thumbnailFrame(for: image.size)
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: frame.width, height: frame.height)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 28)
.fill(.white.opacity(0.14))
.frame(width: 220, height: 160)
.overlay(
Image(systemName: fallbackIconName)
.font(.system(size: 52))
.foregroundStyle(.white.opacity(0.75))
)
}
}
.padding(10)
.background(.ultraThinMaterial.opacity(0.55), in: RoundedRectangle(cornerRadius: 30, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 30, style: .continuous)
.strokeBorder(.white.opacity(0.14))
}
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
.shadow(color: .black.opacity(0.12), radius: 22, y: 10)
}
private func thumbnailFrame(for imageSize: CGSize) -> CGSize {
let aspectRatio = max(0.4, min(3.0, imageSize.width / max(1, imageSize.height)))
let maxHeight = min(thumbnailMaxHeight, max(thumbnailMinHeight, availableHeight))
let widthFromHeight = maxHeight * aspectRatio
if widthFromHeight <= thumbnailMaxWidth {
return CGSize(width: widthFromHeight, height: maxHeight)
}
return CGSize(width: thumbnailMaxWidth, height: thumbnailMaxWidth / aspectRatio)
}
private var fallbackIconName: 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"
}
}
}
struct LaunchRestoreOverlayView: View {
var body: some View {
ZStack {
Rectangle()
.fill(.regularMaterial)
.ignoresSafeArea()
VStack(spacing: 14) {
ProgressView()
.appActivityIndicatorStyle(.large)
Text("Opening Library…")
.font(.title3.weight(.semibold))
}
.padding(.horizontal, 32)
.padding(.vertical, 28)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(.background.opacity(0.92))
)
}
}
}
struct PackReferenceIconView: View {
let iconURL: URL?
let fallbackSystemImage: String
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: fallbackSystemImage)
.font(.caption)
.foregroundStyle(.secondary)
)
}
}
}
struct EmptySourcesView: View {
let isDropTargeted: Bool
let chooseFolder: () -> Void
var body: some View {
VStack(spacing: 24) {
ZStack {
RoundedRectangle(cornerRadius: 24)
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
.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.appAccent : Color.secondary)
}
VStack(spacing: 8) {
Text("Add a Minecraft Source")
.font(.title2)
Text("Choose a copied Minecraft folder or drop one here to start scanning worlds, packs, and templates.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
}
Button("Choose Minecraft Folder...") {
chooseFolder()
}
.controlSize(.large)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(40)
}
}
struct ItemThumbnailView: View {
let iconURL: URL?
let fallbackSystemImage: String = "shippingbox"
var body: some View {
if let image = loadImage(from: iconURL) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 7))
} else {
RoundedRectangle(cornerRadius: 7)
.fill(.quaternary)
.frame(width: 40, height: 40)
.overlay(
Image(systemName: fallbackSystemImage)
.foregroundStyle(.secondary)
)
}
}
}
struct FlexibleTagLayout<Item: Hashable, Content: View>: View {
let spacing: CGFloat
let rowSpacing: CGFloat
let items: [Item]
@ViewBuilder let content: (Item) -> Content
var body: some View {
HStack(spacing: 0) {
GeometryReader { geometry in
generateContent(in: geometry)
}
}
.frame(minHeight: 1)
}
private func generateContent(in geometry: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(items, id: \.self) { item in
content(item)
.alignmentGuide(.leading) { dimensions in
if abs(width - dimensions.width) > geometry.size.width {
width = 0
height -= dimensions.height + rowSpacing
}
let result = width
if item == items.last {
width = 0
} else {
width -= dimensions.width + spacing
}
return result
}
.alignmentGuide(.top) { _ in
let result = height
if item == items.last {
height = 0
}
return result
}
}
}
}
}
struct StorageBreakdown {
let primaryDataSize: Int64?
let resourceSize: Int64?
let metadataSize: Int64?
static let loading = StorageBreakdown(primaryDataSize: nil, resourceSize: nil, metadataSize: nil)
nonisolated static func build(for item: MinecraftContentItem) -> StorageBreakdown {
let fileManager = FileManager.default
switch item.contentType {
case .world:
let dbURL = item.folderURL.appendingPathComponent("db", isDirectory: true)
let behaviorURL = item.folderURL.appendingPathComponent("behavior_packs", isDirectory: true)
let resourceURL = item.folderURL.appendingPathComponent("resource_packs", isDirectory: true)
let primarySize = folderSize(at: dbURL, fileManager: fileManager)
let resourcesSize = (folderSize(at: behaviorURL, fileManager: fileManager) ?? 0)
+ (folderSize(at: resourceURL, fileManager: fileManager) ?? 0)
let totalSize = folderSize(at: item.folderURL, fileManager: fileManager)
let metadata = totalSize.map { total in
max(0, total - (primarySize ?? 0) - resourcesSize)
}
return StorageBreakdown(
primaryDataSize: primarySize,
resourceSize: resourcesSize == 0 ? nil : resourcesSize,
metadataSize: metadata
)
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
let totalSize = folderSize(at: item.folderURL, fileManager: fileManager)
return StorageBreakdown(
primaryDataSize: totalSize,
resourceSize: nil,
metadataSize: nil
)
}
}
var primaryDataText: String {
sizeText(for: primaryDataSize)
}
var resourcesText: String {
sizeText(for: resourceSize)
}
var metadataText: String {
sizeText(for: metadataSize)
}
private func sizeText(for size: Int64?) -> String {
guard let size else {
return "Unavailable"
}
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
}
private nonisolated static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
guard fileManager.fileExists(atPath: folderURL.path) else {
return nil
}
guard let enumerator = fileManager.enumerator(
at: folderURL,
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
options: [.skipsHiddenFiles]
) else {
return nil
}
var totalSize: Int64 = 0
for case let fileURL as URL in enumerator {
guard
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
values.isRegularFile == true,
let fileSize = values.fileSize
else {
continue
}
totalSize += Int64(fileSize)
}
return totalSize
}
}
func loadImage(from url: URL?) -> NSImage? {
guard let url else {
return nil
}
return NSImage(contentsOf: url)
}
func copyToPasteboard(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
extension Color {
static let appAccent = Color("AccentColor")
}