refactor split view to get out of contentarea

This commit is contained in:
John Burwell 2026-05-25 17:57:32 -05:00
parent 6d2ee05786
commit 08574cb259
4 changed files with 360 additions and 201 deletions

View File

@ -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) {
ForEach(library.sources) { source in
SourceHeaderRow(title: source.displayName)
.listRowSeparator(.hidden)
.padding(.top, 6)
.contextMenu {
Button("Rescan \"\(source.displayName)\"") {
library.rescanSource(withID: source.id) library.rescanSource(withID: source.id)
} },
removeSourceAction: { source in
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSource(source.id) removeSource(source.id)
} },
} revealFooterURLAction: revealURLInFinder(_:),
filters: sidebarFilters(for:)
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 {
VStack(spacing: 0) {
ContentCollectionHeaderView(
title: collectionHeaderTitle, title: collectionHeaderTitle,
subtitle: collectionHeaderSubtitle subtitle: collectionHeaderSubtitle,
items: displayedItems,
searchPrompt: searchPrompt,
chooseFolderAction: pickFolder,
dropAction: handleDroppedProviders(_:),
refreshAction: rescanCurrentSource,
itemContextMenu: itemContextMenu(for:)
) )
Divider()
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,
behaviorPacks: packReferences(for: selectedItem, type: .behaviorPack),
resourcePacks: packReferences(for: selectedItem, type: .resourcePack),
contents: directoryPreviewEntries(for: selectedItem),
directoryPreviewLimit: directoryPreviewLimit, directoryPreviewLimit: directoryPreviewLimit,
isEmpty: library.sources.isEmpty,
isPerformingItemAction: isPerformingItemAction, isPerformingItemAction: isPerformingItemAction,
primaryActionTitle: primaryActionTitle(for: selectedItem), exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
primaryActionSubtitle: primaryActionSubtitle(for: selectedItem), exportAction: {
primaryAction: { saveItem(selectedItem) }, guard let item = currentSelectedItem else {
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(selectedExportableItem)
} label: {
Label("Export", systemImage: "arrow.down.circle")
}
.disabled(isPerformingItemAction)
Button {
revealInFinder(selectedExportableItem)
} label: {
Label("Reveal in Finder", systemImage: "folder")
}
.disabled(isPerformingItemAction)
}
}
}
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
guard let selectedItem, !filteredIDs.contains(selectedItem.id) else {
return return
} }
self.selectedItem = nil saveItem(item)
},
revealAction: {
guard let item = currentSelectedItem else {
return
}
revealInFinder(item)
},
shareAction: { anchorView in
guard let item = currentSelectedItem else {
return
}
shareItem(item, from: anchorView)
}
)
}
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return
}
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,
chooseFolder: chooseFolderAction
)
.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")
Text(subtitle) Menu {
.font(.subheadline) 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) .foregroundStyle(.secondary)
} }
.padding(.horizontal, 16) }
.padding(.vertical, 14) .toolbar {
.frame(maxWidth: .infinity, alignment: .leading) if item != nil {
.background(.background) 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")
}
}
}
} }
} }
@ -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")

View File

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

View File

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

View File

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