Move search back to toolbar. still don't love the UI but gettin gthere

This commit is contained in:
John Burwell 2026-05-25 16:11:28 -05:00
parent 711ac54f00
commit 2932ac2f48
2 changed files with 180 additions and 114 deletions

View File

@ -33,15 +33,18 @@ struct ContentView: View {
} }
} }
.listStyle(.sidebar) .listStyle(.sidebar)
.navigationTitle("Sources")
Divider() if library.sidebarFooterState.style != .idle {
SidebarFooterView(
SidebarFooterView( state: library.sidebarFooterState,
state: library.sidebarFooterState, revealAction: revealURLInFinder(_:)
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 { if library.sources.isEmpty {
EmptySourcesView( EmptySourcesView(
@ -53,9 +56,7 @@ struct ContentView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ContentCollectionHeaderView( ContentCollectionHeaderView(
title: collectionHeaderTitle, title: collectionHeaderTitle,
subtitle: collectionHeaderSubtitle, subtitle: collectionHeaderSubtitle
prompt: searchPrompt,
searchText: $searchText
) )
Divider() Divider()
@ -93,8 +94,8 @@ struct ContentView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.navigationTitle("Minecraft World Manager")
.tint(.minecraftAccent) .tint(.minecraftAccent)
.searchable(text: $searchText, placement: .toolbar, prompt: searchPrompt)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .primaryAction) { ToolbarItemGroup(placement: .primaryAction) {
if let selectedExportableItem = selectedExportableItem { if let selectedExportableItem = selectedExportableItem {
@ -205,6 +206,10 @@ struct ContentView: View {
} }
private var collectionHeaderTitle: String { private var collectionHeaderTitle: String {
if isSearching {
return "Searching “\(searchScopeTitle)"
}
guard let selectedSidebarSelection else { guard let selectedSidebarSelection else {
return "Minecraft Content" return "Minecraft Content"
} }
@ -222,13 +227,28 @@ struct ContentView: View {
let filteredCount = filteredItems.count let filteredCount = filteredItems.count
let noun = collectionCountNoun let noun = collectionCountNoun
if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !isSearching {
return "\(totalCount.formatted(.number)) \(noun)" return "\(totalCount.formatted(.number)) \(noun)"
} }
return "\(filteredCount.formatted(.number)) of \(totalCount.formatted(.number)) \(noun)" return "\(filteredCount.formatted(.number)) of \(totalCount.formatted(.number)) \(noun)"
} }
private var searchScopeTitle: String {
switch selectedSidebarSelection {
case .some(.allContent):
return "All"
case .some(.contentType(_, let contentType)):
return sidebarTitle(for: contentType)
case .none:
return "Content"
}
}
private var isSearching: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var collectionCountNoun: String { private var collectionCountNoun: String {
guard let selectedSidebarSelection else { guard let selectedSidebarSelection else {
return "items" return "items"
@ -658,7 +678,7 @@ private struct SidebarFooterView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(.bar) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
} }
private var primaryColor: Color { private var primaryColor: Color {
@ -676,22 +696,18 @@ private struct SidebarFooterView: View {
private struct ContentCollectionHeaderView: View { private struct ContentCollectionHeaderView: View {
let title: String let title: String
let subtitle: String let subtitle: String
let prompt: String
@Binding var searchText: String
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) {
Text(title) Text(title)
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
Text(subtitle) Text(subtitle)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
TextField(prompt, text: $searchText)
.textFieldStyle(.roundedBorder)
} }
.padding(16) .padding(.horizontal, 16)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(.background) .background(.background)
} }
@ -752,9 +768,10 @@ private struct ItemDetailView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 18) {
LargeItemThumbnailView(iconURL: item.iconURL) LargeItemThumbnailView(iconURL: item.iconURL, contentType: item.contentType)
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(item.displayName) Text(item.displayName)
@ -764,49 +781,60 @@ private struct ItemDetailView: View {
.font(.title3) .font(.title3)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
HStack(spacing: 18) {
metadataChip(title: "Size", value: sizeText)
metadataChip(title: item.displayDateLabel, value: displayDateText)
if item.lastPlayedDate == nil, item.contentType == .world {
metadataChip(title: "Last Played", value: "Not available")
}
}
} }
VStack(alignment: .leading, spacing: 12) { detailCard {
Text("Actions") VStack(alignment: .leading, spacing: 14) {
.font(.headline) Text("Actions")
.font(.headline)
Button(primaryActionTitle) { Button(primaryActionTitle) {
primaryAction() primaryAction()
}
.buttonStyle(.borderedProminent)
.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()
} }
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isPerformingItemAction) .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)
}
} }
} }
if item.contentType == .world { detailCard {
if !behaviorPacks.isEmpty || !resourcePacks.isEmpty { VStack(alignment: .leading, spacing: 14) {
Text("Details")
.font(.headline)
detailValueRow(title: "Size", value: sizeText)
detailValueRow(title: item.displayDateLabel, value: displayDateText)
if item.contentType == .world {
detailValueRow(
title: "Last Played",
value: item.lastPlayedDate?.formatted(date: .abbreviated, time: .omitted) ?? "Not available"
)
}
}
}
if item.contentType == .world, !behaviorPacks.isEmpty || !resourcePacks.isEmpty {
detailCard {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
Text("Packs Used") Text("Packs Used")
.font(.headline) .font(.headline)
@ -822,55 +850,55 @@ private struct ItemDetailView: View {
} }
} }
DisclosureGroup("Technical Details") { detailCard {
VStack(alignment: .leading, spacing: 18) { DisclosureGroup("Technical Details") {
detailRow(title: "Folder ID", value: item.folderID) VStack(alignment: .leading, spacing: 18) {
detailRow(title: "Folder Path", value: item.folderURL.path) detailRow(title: "Folder ID", value: item.folderID)
detailRow(title: "Collection Root", value: item.collectionRootURL.path) detailRow(title: "Folder Path", value: item.folderURL.path)
detailRow(title: "Collection Root", value: item.collectionRootURL.path)
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Contents") Text("Contents")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary)
if contents.isEmpty {
Text("No visible files or folders")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else {
ForEach(contents) { entry in
HStack(spacing: 10) {
Image(systemName: entry.isDirectory ? "folder" : "doc")
.foregroundStyle(.secondary)
Text(entry.name)
.lineLimit(1)
Spacer()
}
}
if contents.count == directoryPreviewLimit { if contents.isEmpty {
Text("Showing the first \(directoryPreviewLimit) items") Text("No visible files or folders")
.font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else {
ForEach(contents) { entry in
HStack(spacing: 10) {
Image(systemName: entry.isDirectory ? "folder" : "doc")
.foregroundStyle(.secondary)
Text(entry.name)
.lineLimit(1)
Spacer()
}
}
if contents.count == directoryPreviewLimit {
Text("Showing the first \(directoryPreviewLimit) items")
.font(.caption)
.foregroundStyle(.secondary)
}
} }
} }
} }
.padding(.top, 8)
} }
.padding(.top, 8)
} }
} }
.padding(24) .padding(28)
.frame(maxWidth: 760, alignment: .leading)
} }
} }
@ViewBuilder @ViewBuilder
private func metadataChip(title: String, value: String) -> some View { private func detailCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 4) { content()
Text(title) .frame(maxWidth: .infinity, alignment: .leading)
.font(.caption) .padding(18)
.foregroundStyle(.secondary) .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
Text(value)
.font(.body.weight(.medium))
}
} }
@ViewBuilder @ViewBuilder
@ -905,6 +933,18 @@ private struct ItemDetailView: View {
} }
} }
@ViewBuilder
private func detailValueRow(title: String, value: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text(title)
.foregroundStyle(.secondary)
Spacer()
Text(value)
.fontWeight(.medium)
.multilineTextAlignment(.trailing)
}
}
private var sizeText: String { private var sizeText: String {
item.sizeBytes.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "Unknown" item.sizeBytes.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "Unknown"
} }
@ -1034,25 +1074,41 @@ private struct ItemThumbnailView: View {
private struct LargeItemThumbnailView: View { private struct LargeItemThumbnailView: View {
let iconURL: URL? let iconURL: URL?
let contentType: MinecraftContentType
var body: some View { var body: some View {
if let image = loadImage(from: iconURL) { if let image = loadImage(from: iconURL) {
Image(nsImage: image) Image(nsImage: image)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 180, height: 180) .frame(maxWidth: 420, minHeight: 260, maxHeight: 340)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 28))
} else { } else {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 28)
.fill(.quaternary) .fill(.quaternary)
.frame(width: 180, height: 180) .frame(maxWidth: 420, minHeight: 260, maxHeight: 340)
.overlay( .overlay(
Image(systemName: "shippingbox") Image(systemName: fallbackIconName)
.font(.largeTitle) .font(.system(size: 56))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
) )
} }
} }
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"
}
}
} }
private func loadImage(from url: URL?) -> NSImage? { private func loadImage(from url: URL?) -> NSImage? {

View File

@ -27,12 +27,13 @@ final class SourceLibrary: ObservableObject {
@Published var sources: [MinecraftSource] = [] @Published var sources: [MinecraftSource] = []
@Published private(set) var sidebarFooterState = SidebarFooterState( @Published private(set) var sidebarFooterState = SidebarFooterState(
style: .idle, style: .idle,
title: "Ready", title: "",
subtitle: nil, subtitle: nil,
revealURL: nil revealURL: nil
) )
private var scanTasks: [URL: Task<Void, Never>] = [:] private var scanTasks: [URL: Task<Void, Never>] = [:]
private var footerResetTask: Task<Void, Never>?
func addSource(at url: URL) -> URL { func addSource(at url: URL) -> URL {
let normalizedURL = url.standardizedFileURL let normalizedURL = url.standardizedFileURL
@ -64,6 +65,7 @@ final class SourceLibrary: ObservableObject {
} }
func setItemActionInProgress(_ description: String) { func setItemActionInProgress(_ description: String) {
cancelFooterReset()
sidebarFooterState = SidebarFooterState( sidebarFooterState = SidebarFooterState(
style: .inProgress, style: .inProgress,
title: description, title: description,
@ -79,6 +81,7 @@ final class SourceLibrary: ObservableObject {
subtitle: message, subtitle: message,
revealURL: nil revealURL: nil
) )
scheduleFooterReset()
} }
func setItemActionSuccess(title: String, subtitle: String, revealURL: URL?) { func setItemActionSuccess(title: String, subtitle: String, revealURL: URL?) {
@ -88,6 +91,7 @@ final class SourceLibrary: ObservableObject {
subtitle: subtitle, subtitle: subtitle,
revealURL: revealURL revealURL: revealURL
) )
scheduleFooterReset()
} }
var activeScanSummary: String? { var activeScanSummary: String? {
@ -216,7 +220,7 @@ final class SourceLibrary: ObservableObject {
private func refreshSidebarFooterState() { private func refreshSidebarFooterState() {
let scanningSources = sources.filter(\.isScanning) let scanningSources = sources.filter(\.isScanning)
if let source = scanningSources.first { if let source = scanningSources.first {
let title = source.itemCount == 0 ? "Scanning worlds..." : "Scanning worlds..." cancelFooterReset()
let subtitle: String let subtitle: String
if source.indexedItemCount > 0 { if source.indexedItemCount > 0 {
subtitle = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" subtitle = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
@ -226,7 +230,7 @@ final class SourceLibrary: ObservableObject {
sidebarFooterState = SidebarFooterState( sidebarFooterState = SidebarFooterState(
style: .inProgress, style: .inProgress,
title: title, title: "Scanning...",
subtitle: subtitle, subtitle: subtitle,
revealURL: nil revealURL: nil
) )
@ -240,21 +244,27 @@ final class SourceLibrary: ObservableObject {
subtitle: source.scanError, subtitle: source.scanError,
revealURL: nil revealURL: nil
) )
scheduleFooterReset()
return return
} }
cancelFooterReset()
sidebarFooterState = SidebarFooterState(style: .idle, title: "", subtitle: nil, revealURL: nil)
}
let totalItems = sources.reduce(0) { $0 + $1.itemCount } private func cancelFooterReset() {
let subtitle = totalItems == 0 ? "No content indexed" : "\(totalItems.formatted(.number)) items indexed" footerResetTask?.cancel()
let lastUpdatedDate = sources.compactMap(\.lastScanDate).max() footerResetTask = nil
let secondaryText = lastUpdatedDate.map { }
"\(subtitle) \u{2022} Last updated \($0.formatted(.relative(presentation: .named)))"
} ?? subtitle
sidebarFooterState = SidebarFooterState( private func scheduleFooterReset(after seconds: Double = 5) {
style: .idle, cancelFooterReset()
title: "Ready", footerResetTask = Task { @MainActor [weak self] in
subtitle: secondaryText, try? await Task.sleep(for: .seconds(seconds))
revealURL: nil guard let self, !Task.isCancelled else {
) return
}
self.refreshSidebarFooterState()
}
} }
} }