refactor split view to get out of contentarea
This commit is contained in:
parent
6d2ee05786
commit
08574cb259
@ -11,142 +11,86 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var library = SourceLibrary()
|
@StateObject private var library = SourceLibrary()
|
||||||
@State private var selectedItem: MinecraftContentItem?
|
@State private var selectedItemID: MinecraftContentItem.ID?
|
||||||
@State private var selectedSidebarSelection: SidebarSelection?
|
@State private var selectedSidebarSelection: SidebarSelection?
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var isDropTargeted = false
|
@State private var isDropTargeted = false
|
||||||
@State private var isPerformingItemAction = false
|
@State private var isPerformingItemAction = false
|
||||||
|
@State private var sortMode: ItemSortMode = .name
|
||||||
|
|
||||||
private let directoryPreviewLimit = 12
|
private let directoryPreviewLimit = 12
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
VStack(spacing: 0) {
|
SourcesSidebarView(
|
||||||
SidebarSourcesHeaderView(addSourceAction: pickFolder)
|
sources: library.sources,
|
||||||
.padding(.horizontal, 12)
|
selection: $selectedSidebarSelection,
|
||||||
.padding(.top, 8)
|
footerState: library.sidebarFooterState,
|
||||||
.padding(.bottom, 4)
|
addSourceAction: pickFolder,
|
||||||
|
rescanSourceAction: { source in
|
||||||
List(selection: $selectedSidebarSelection) {
|
library.rescanSource(withID: source.id)
|
||||||
ForEach(library.sources) { source in
|
},
|
||||||
SourceHeaderRow(title: source.displayName)
|
removeSourceAction: { source in
|
||||||
.listRowSeparator(.hidden)
|
removeSource(source.id)
|
||||||
.padding(.top, 6)
|
},
|
||||||
.contextMenu {
|
revealFooterURLAction: revealURLInFinder(_:),
|
||||||
Button("Rescan \"\(source.displayName)\"") {
|
filters: sidebarFilters(for:)
|
||||||
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?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.sidebar)
|
|
||||||
|
|
||||||
if library.sidebarFooterState.style != .idle {
|
|
||||||
SidebarFooterView(
|
|
||||||
state: library.sidebarFooterState,
|
|
||||||
revealAction: revealURLInFinder(_:)
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: library.sidebarFooterState.style)
|
|
||||||
} content: {
|
} content: {
|
||||||
if library.sources.isEmpty {
|
ItemListColumnView(
|
||||||
EmptySourcesView(
|
isEmpty: library.sources.isEmpty,
|
||||||
isDropTargeted: isDropTargeted,
|
isDropTargeted: $isDropTargeted,
|
||||||
chooseFolder: pickFolder
|
selectedItemID: $selectedItemID,
|
||||||
)
|
searchText: $searchText,
|
||||||
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: handleDroppedProviders)
|
sortMode: $sortMode,
|
||||||
} else {
|
title: collectionHeaderTitle,
|
||||||
VStack(spacing: 0) {
|
subtitle: collectionHeaderSubtitle,
|
||||||
ContentCollectionHeaderView(
|
items: displayedItems,
|
||||||
title: collectionHeaderTitle,
|
searchPrompt: searchPrompt,
|
||||||
subtitle: collectionHeaderSubtitle
|
chooseFolderAction: pickFolder,
|
||||||
)
|
dropAction: handleDroppedProviders(_:),
|
||||||
|
refreshAction: rescanCurrentSource,
|
||||||
Divider()
|
itemContextMenu: itemContextMenu(for:)
|
||||||
|
)
|
||||||
List(filteredItems, selection: $selectedItem) { item in
|
|
||||||
ContentRowView(item: item)
|
|
||||||
.tag(item)
|
|
||||||
.contextMenu {
|
|
||||||
itemContextMenu(for: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.inset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} detail: {
|
} detail: {
|
||||||
if library.sources.isEmpty {
|
ItemDetailColumnView(
|
||||||
Text("Add a source folder to start scanning Minecraft content")
|
item: currentSelectedItem,
|
||||||
.foregroundStyle(.secondary)
|
behaviorPacks: currentSelectedItem.map { packReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||||
} else if let selectedItem = currentSelectedItem {
|
resourcePacks: currentSelectedItem.map { packReferences(for: $0, type: .resourcePack) } ?? [],
|
||||||
ItemDetailView(
|
contents: currentSelectedItem.map(directoryPreviewEntries(for:)) ?? [],
|
||||||
item: selectedItem,
|
directoryPreviewLimit: directoryPreviewLimit,
|
||||||
behaviorPacks: packReferences(for: selectedItem, type: .behaviorPack),
|
isEmpty: library.sources.isEmpty,
|
||||||
resourcePacks: packReferences(for: selectedItem, type: .resourcePack),
|
isPerformingItemAction: isPerformingItemAction,
|
||||||
contents: directoryPreviewEntries(for: selectedItem),
|
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
|
||||||
directoryPreviewLimit: directoryPreviewLimit,
|
exportAction: {
|
||||||
isPerformingItemAction: isPerformingItemAction,
|
guard let item = currentSelectedItem else {
|
||||||
primaryActionTitle: primaryActionTitle(for: selectedItem),
|
return
|
||||||
primaryActionSubtitle: primaryActionSubtitle(for: selectedItem),
|
|
||||||
primaryAction: { saveItem(selectedItem) },
|
|
||||||
shareAction: { anchorView in shareItem(selectedItem, from: anchorView) },
|
|
||||||
revealAction: { revealInFinder(selectedItem) }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Select a world or pack to see details")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.searchable(text: $searchText, placement: .toolbar, prompt: searchPrompt)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
|
||||||
if let selectedExportableItem = selectedExportableItem {
|
|
||||||
SharingPickerButton(
|
|
||||||
title: nil,
|
|
||||||
systemImage: "square.and.arrow.up",
|
|
||||||
isEnabled: !isPerformingItemAction
|
|
||||||
) { anchorView in
|
|
||||||
shareItem(selectedExportableItem, from: anchorView)
|
|
||||||
}
|
}
|
||||||
.help("Share")
|
|
||||||
|
|
||||||
Button {
|
saveItem(item)
|
||||||
saveItem(selectedExportableItem)
|
},
|
||||||
} label: {
|
revealAction: {
|
||||||
Label("Export", systemImage: "arrow.down.circle")
|
guard let item = currentSelectedItem else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
|
|
||||||
Button {
|
revealInFinder(item)
|
||||||
revealInFinder(selectedExportableItem)
|
},
|
||||||
} label: {
|
shareAction: { anchorView in
|
||||||
Label("Reveal in Finder", systemImage: "folder")
|
guard let item = currentSelectedItem else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
|
shareItem(item, from: anchorView)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
|
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
||||||
guard let selectedItem, !filteredIDs.contains(selectedItem.id) else {
|
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.selectedItem = nil
|
self.selectedItemID = nil
|
||||||
}
|
}
|
||||||
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
|
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
|
||||||
syncSelection(with: sourceIDs)
|
syncSelection(with: sourceIDs)
|
||||||
@ -177,6 +121,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var displayedItems: [MinecraftContentItem] {
|
||||||
|
filteredItems.sorted(by: sortComparator)
|
||||||
|
}
|
||||||
|
|
||||||
private var currentSource: MinecraftSource? {
|
private var currentSource: MinecraftSource? {
|
||||||
guard let sourceID = selectedSidebarSelection?.sourceID else {
|
guard let sourceID = selectedSidebarSelection?.sourceID else {
|
||||||
return library.sources.first
|
return library.sources.first
|
||||||
@ -186,17 +134,56 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var currentSelectedItem: MinecraftContentItem? {
|
private var currentSelectedItem: MinecraftContentItem? {
|
||||||
guard let selectedItem else {
|
guard let selectedItemID else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return library.sources
|
return library.sources
|
||||||
.flatMap(\.items)
|
.flatMap(\.items)
|
||||||
.first(where: { $0.id == selectedItem.id }) ?? selectedItem
|
.first(where: { $0.id == selectedItemID })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedExportableItem: MinecraftContentItem? {
|
private var sortComparator: (MinecraftContentItem, MinecraftContentItem) -> Bool {
|
||||||
currentSelectedItem
|
switch sortMode {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var collectionHeaderTitle: String {
|
private var collectionHeaderTitle: String {
|
||||||
@ -205,12 +192,12 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let selectedSidebarSelection else {
|
guard let selectedSidebarSelection else {
|
||||||
return "Minecraft Content"
|
return "Library"
|
||||||
}
|
}
|
||||||
|
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .allContent:
|
case .allContent:
|
||||||
return "All Content"
|
return "All Items"
|
||||||
case .contentType(_, let contentType):
|
case .contentType(_, let contentType):
|
||||||
return sidebarTitle(for: contentType)
|
return sidebarTitle(for: contentType)
|
||||||
}
|
}
|
||||||
@ -235,7 +222,7 @@ struct ContentView: View {
|
|||||||
case .some(.contentType(_, let contentType)):
|
case .some(.contentType(_, let contentType)):
|
||||||
return sidebarTitle(for: contentType)
|
return sidebarTitle(for: contentType)
|
||||||
case .none:
|
case .none:
|
||||||
return "Content"
|
return "Library"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,18 +251,18 @@ struct ContentView: View {
|
|||||||
private var searchPrompt: String {
|
private var searchPrompt: String {
|
||||||
switch selectedSidebarSelection {
|
switch selectedSidebarSelection {
|
||||||
case .some(.allContent):
|
case .some(.allContent):
|
||||||
return "Search All Content"
|
return "Search All Items"
|
||||||
case .some(.contentType(_, let contentType)):
|
case .some(.contentType(_, let contentType)):
|
||||||
return "Search \(sidebarTitle(for: contentType))"
|
return "Search \(sidebarTitle(for: contentType))"
|
||||||
case .none:
|
case .none:
|
||||||
return "Search Content"
|
return "Search Library"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
||||||
var filters = [
|
var filters = [
|
||||||
SidebarFilter(
|
SidebarFilter(
|
||||||
title: "All Content",
|
title: "All Items",
|
||||||
iconName: "square.grid.2x2",
|
iconName: "square.grid.2x2",
|
||||||
count: source.items.count,
|
count: source.items.count,
|
||||||
selection: .allContent(sourceID: source.id)
|
selection: .allContent(sourceID: source.id)
|
||||||
@ -471,8 +458,8 @@ struct ContentView: View {
|
|||||||
selectedSidebarSelection = fallbackSourceID.map { .allContent(sourceID: $0) }
|
selectedSidebarSelection = fallbackSourceID.map { .allContent(sourceID: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let selectedItem, currentSelectedItem?.id != selectedItem.id {
|
if let selectedItemID, currentSelectedItem?.id != selectedItemID {
|
||||||
self.selectedItem = nil
|
self.selectedItemID = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,13 +470,13 @@ struct ContentView: View {
|
|||||||
self.selectedSidebarSelection = .allContent(sourceID: firstSourceID)
|
self.selectedSidebarSelection = .allContent(sourceID: firstSourceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let selectedItem {
|
if let selectedItemID {
|
||||||
let itemStillExists = library.sources
|
let itemStillExists = library.sources
|
||||||
.flatMap(\.items)
|
.flatMap(\.items)
|
||||||
.contains(where: { $0.id == selectedItem.id })
|
.contains(where: { $0.id == selectedItemID })
|
||||||
|
|
||||||
if !itemStillExists {
|
if !itemStillExists {
|
||||||
self.selectedItem = nil
|
self.selectedItemID = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -588,6 +575,14 @@ struct ContentView: View {
|
|||||||
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
NSWorkspace.shared.activateFileViewerSelecting([item.folderURL])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func rescanCurrentSource() {
|
||||||
|
guard let sourceID = selectedSidebarSelection?.sourceID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
library.rescanSource(withID: sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
private func revealURLInFinder(_ url: URL) {
|
private func revealURLInFinder(_ url: URL) {
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
}
|
}
|
||||||
@ -609,6 +604,25 @@ private enum SidebarSelection: Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum ItemSortMode: String, CaseIterable, Identifiable {
|
||||||
|
case name
|
||||||
|
case modifiedDate
|
||||||
|
case size
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .name:
|
||||||
|
return "Name"
|
||||||
|
case .modifiedDate:
|
||||||
|
return "Modified Date"
|
||||||
|
case .size:
|
||||||
|
return "Size"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct SidebarFilter: Identifiable, Hashable {
|
private struct SidebarFilter: Identifiable, Hashable {
|
||||||
var id: SidebarSelection { selection }
|
var id: SidebarSelection { selection }
|
||||||
let title: String
|
let title: String
|
||||||
@ -617,6 +631,68 @@ private struct SidebarFilter: Identifiable, Hashable {
|
|||||||
let selection: SidebarSelection
|
let selection: SidebarSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct SourcesSidebarView: View {
|
||||||
|
let sources: [MinecraftSource]
|
||||||
|
@Binding var selection: SidebarSelection?
|
||||||
|
let footerState: SidebarFooterState
|
||||||
|
let addSourceAction: () -> Void
|
||||||
|
let rescanSourceAction: (MinecraftSource) -> Void
|
||||||
|
let removeSourceAction: (MinecraftSource) -> Void
|
||||||
|
let revealFooterURLAction: (URL) -> Void
|
||||||
|
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(selection: $selection) {
|
||||||
|
Section {
|
||||||
|
ForEach(sources) { source in
|
||||||
|
SourceHeaderRow(title: source.displayName)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.contextMenu {
|
||||||
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
|
rescanSourceAction(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||||
|
removeSourceAction(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(filters(source)) { filter in
|
||||||
|
SidebarFilterRow(filter: filter, isIndented: true)
|
||||||
|
.tag(filter.selection as SidebarSelection?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
SidebarSourcesSectionHeaderView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if footerState.style != .idle {
|
||||||
|
SidebarFooterView(
|
||||||
|
state: footerState,
|
||||||
|
revealAction: revealFooterURLAction
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem {
|
||||||
|
Button(action: addSourceAction) {
|
||||||
|
Image(systemName: "folder.badge.plus")
|
||||||
|
}
|
||||||
|
.help("Add Source Folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct SidebarFilterRow: View {
|
private struct SidebarFilterRow: View {
|
||||||
let filter: SidebarFilter
|
let filter: SidebarFilter
|
||||||
let isIndented: Bool
|
let isIndented: Bool
|
||||||
@ -638,23 +714,12 @@ private struct SidebarFilterRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SidebarSourcesHeaderView: View {
|
private struct SidebarSourcesSectionHeaderView: View {
|
||||||
let addSourceAction: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
Text("Library")
|
||||||
Text("Sources")
|
.font(.headline)
|
||||||
.font(.headline)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
.textCase(nil)
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: addSourceAction) {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.help("Add Source")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -719,23 +784,124 @@ private struct SidebarFooterView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ContentCollectionHeaderView: View {
|
private struct ItemListColumnView<MenuContent: View>: View {
|
||||||
|
let isEmpty: Bool
|
||||||
|
@Binding var isDropTargeted: Bool
|
||||||
|
@Binding var selectedItemID: MinecraftContentItem.ID?
|
||||||
|
@Binding var searchText: String
|
||||||
|
@Binding var sortMode: ItemSortMode
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
|
let items: [MinecraftContentItem]
|
||||||
|
let searchPrompt: String
|
||||||
|
let chooseFolderAction: () -> Void
|
||||||
|
let dropAction: ([NSItemProvider]) -> Bool
|
||||||
|
let refreshAction: () -> Void
|
||||||
|
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Group {
|
||||||
Text(title)
|
if isEmpty {
|
||||||
.font(.title2.weight(.semibold))
|
EmptySourcesView(
|
||||||
|
isDropTargeted: isDropTargeted,
|
||||||
Text(subtitle)
|
chooseFolder: chooseFolderAction
|
||||||
.font(.subheadline)
|
)
|
||||||
.foregroundStyle(.secondary)
|
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction)
|
||||||
|
} else {
|
||||||
|
List(items, selection: $selectedItemID) { item in
|
||||||
|
ContentRowView(item: item)
|
||||||
|
.tag(item.id)
|
||||||
|
.contextMenu {
|
||||||
|
itemContextMenu(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.inset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: searchPrompt)
|
||||||
|
.navigationTitle(isEmpty ? "Library" : title)
|
||||||
|
.navigationSubtitle(isEmpty ? "" : subtitle)
|
||||||
|
.toolbar {
|
||||||
|
if !isEmpty {
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Button(action: refreshAction) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.help("Rescan Source")
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Picker("Sort By", selection: $sortMode) {
|
||||||
|
ForEach(ItemSortMode.allCases) { mode in
|
||||||
|
Text(mode.title).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
.help("List Options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ItemDetailColumnView: View {
|
||||||
|
let item: MinecraftContentItem?
|
||||||
|
let behaviorPacks: [ContentPackReference]
|
||||||
|
let resourcePacks: [ContentPackReference]
|
||||||
|
let contents: [DirectoryPreviewEntry]
|
||||||
|
let directoryPreviewLimit: Int
|
||||||
|
let isEmpty: Bool
|
||||||
|
let isPerformingItemAction: Bool
|
||||||
|
let exportTitle: String?
|
||||||
|
let exportAction: () -> Void
|
||||||
|
let revealAction: () -> Void
|
||||||
|
let shareAction: (NSView?) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isEmpty {
|
||||||
|
Text("Add a source folder to start scanning your Minecraft library")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let item {
|
||||||
|
ItemDetailView(
|
||||||
|
item: item,
|
||||||
|
behaviorPacks: behaviorPacks,
|
||||||
|
resourcePacks: resourcePacks,
|
||||||
|
contents: contents,
|
||||||
|
directoryPreviewLimit: directoryPreviewLimit
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Select a world or pack to see details")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
if item != nil {
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Button(action: exportAction) {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
.disabled(isPerformingItemAction)
|
||||||
|
.help(exportTitle ?? "Export")
|
||||||
|
|
||||||
|
Button(action: revealAction) {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
}
|
||||||
|
.disabled(isPerformingItemAction)
|
||||||
|
.help("Reveal in Finder")
|
||||||
|
|
||||||
|
SharingPickerButton(
|
||||||
|
title: nil,
|
||||||
|
systemImage: "square.and.arrow.up",
|
||||||
|
isEnabled: !isPerformingItemAction
|
||||||
|
) { anchorView in
|
||||||
|
shareAction(anchorView)
|
||||||
|
}
|
||||||
|
.help("Share")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(.background)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -785,12 +951,6 @@ private struct ItemDetailView: View {
|
|||||||
let resourcePacks: [ContentPackReference]
|
let resourcePacks: [ContentPackReference]
|
||||||
let contents: [DirectoryPreviewEntry]
|
let contents: [DirectoryPreviewEntry]
|
||||||
let directoryPreviewLimit: Int
|
let directoryPreviewLimit: Int
|
||||||
let isPerformingItemAction: Bool
|
|
||||||
let primaryActionTitle: String
|
|
||||||
let primaryActionSubtitle: String
|
|
||||||
let primaryAction: () -> Void
|
|
||||||
let shareAction: (NSView) -> Void
|
|
||||||
let revealAction: () -> Void
|
|
||||||
@State private var isTechnicalDetailsExpanded = false
|
@State private var isTechnicalDetailsExpanded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -810,39 +970,6 @@ private struct ItemDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
detailCard {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
Text("Actions")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Button(primaryActionTitle) {
|
|
||||||
primaryAction()
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.large)
|
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
|
|
||||||
Text(primaryActionSubtitle)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
SharingPickerButton(
|
|
||||||
title: "Share...",
|
|
||||||
systemImage: "square.and.arrow.up",
|
|
||||||
isEnabled: !isPerformingItemAction
|
|
||||||
) { anchorView in
|
|
||||||
shareAction(anchorView)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Reveal in Finder") {
|
|
||||||
revealAction()
|
|
||||||
}
|
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detailCard {
|
detailCard {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
Text("Details")
|
Text("Details")
|
||||||
|
|||||||
@ -155,7 +155,7 @@ enum ContentPackageExporter {
|
|||||||
)
|
)
|
||||||
let trimmedPunctuation = normalizedWhitespace.trimmingCharacters(in: CharacterSet(charactersIn: " .-_"))
|
let trimmedPunctuation = normalizedWhitespace.trimmingCharacters(in: CharacterSet(charactersIn: " .-_"))
|
||||||
|
|
||||||
return trimmedPunctuation.isEmpty ? "Minecraft Content" : trimmedPunctuation
|
return trimmedPunctuation.isEmpty ? "Minecraft Item" : trimmedPunctuation
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func portableASCIIString(from value: String) -> String {
|
nonisolated private static func portableASCIIString(from value: String) -> String {
|
||||||
|
|||||||
@ -125,7 +125,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.isScanning = true
|
source.isScanning = true
|
||||||
source.scanError = nil
|
source.scanError = nil
|
||||||
source.scanStatus = "Searching for Minecraft content..."
|
source.scanStatus = "Scanning Minecraft library..."
|
||||||
source.items = []
|
source.items = []
|
||||||
source.indexedItemCount = 0
|
source.indexedItemCount = 0
|
||||||
source.indexedDetailCount = 0
|
source.indexedDetailCount = 0
|
||||||
@ -185,7 +185,7 @@ final class SourceLibrary: ObservableObject {
|
|||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.items.sort(by: WorldScanner.sortItems)
|
source.items.sort(by: WorldScanner.sortItems)
|
||||||
source.scanStatus = source.indexedItemCount == 0
|
source.scanStatus = source.indexedItemCount == 0
|
||||||
? "No Minecraft content found."
|
? "No Minecraft items found."
|
||||||
: "Loaded \(source.indexedDetailCount) items."
|
: "Loaded \(source.indexedDetailCount) items."
|
||||||
source.isScanning = false
|
source.isScanning = false
|
||||||
source.lastScanDate = Date()
|
source.lastScanDate = Date()
|
||||||
|
|||||||
@ -13,7 +13,39 @@ struct World_Manager_for_MinecraftApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.tint(Color("AccentColor"))
|
.tint(Color("AccentColor"))
|
||||||
|
.background(WindowChromeConfigurator())
|
||||||
}
|
}
|
||||||
.windowToolbarStyle(.unifiedCompact(showsTitle: false))
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
.windowToolbarStyle(.unified(showsTitle: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WindowChromeConfigurator: NSViewRepresentable {
|
||||||
|
func makeNSView(context: Context) -> NSView {
|
||||||
|
let view = NSView()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
configureWindow(for: view)
|
||||||
|
}
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
configureWindow(for: nsView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureWindow(for view: NSView) {
|
||||||
|
guard let window = view.window else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.toolbarStyle = .unified
|
||||||
|
window.styleMask.insert(.fullSizeContentView)
|
||||||
|
window.isMovableByWindowBackground = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user