world-manager/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift

537 lines
15 KiB
Swift

// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL)
case sourceCandidate(candidateID: String)
case connectedDevice(deviceID: String)
case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType)
case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
var sourceID: URL? {
switch self {
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
return sourceID
case .sourceCandidate, .connectedDevice:
return nil
}
}
}
struct SidebarFilter: Identifiable, Hashable {
var id: SidebarSelection { selection }
let title: String
let iconName: String
let count: Int
let selection: SidebarSelection
}
struct SourcesSidebarView: View {
let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry]
let sourceCandidates: [SourceCandidate]
let isDiscoveringSourceCandidates: Bool
@Binding var selection: SidebarSelection?
let addSourceAction: () -> Void
let discoverSourcesAction: () -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let addDeviceSourceAction: () -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
let rescanSourceAction: (MinecraftSource) -> Void
let removeSourceAction: (MinecraftSource) -> Void
let filters: (MinecraftSource) -> [SidebarFilter]
var body: some View {
List(selection: $selection) {
if !sources.isEmpty {
Section {
ForEach(sources) { source in
sourceSectionRows(for: source)
}
} header: {
SidebarSourcesSectionHeaderView(title: "Libraries")
}
}
if !connectedDevices.isEmpty {
Section {
ForEach(connectedDevices) { entry in
connectedDeviceSectionRows(for: entry)
}
} header: {
SidebarSourcesSectionHeaderView(title: "Available Devices")
}
}
if !sourceCandidates.isEmpty {
Section {
ForEach(sourceCandidates) { candidate in
SourceCandidateRow(
candidate: candidate,
onSelect: {
selection = .sourceCandidate(candidateID: candidate.id)
},
addAction: {
addCandidateSourceAction(candidate)
}
)
.tag(SidebarSelection.sourceCandidate(candidateID: candidate.id) as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
}
} header: {
SidebarSourcesSectionHeaderView(title: "Found Sources")
}
}
}
.listStyle(.sidebar)
.toolbar {
ToolbarItem {
Button(action: discoverSourcesAction) {
if isDiscoveringSourceCandidates {
ProgressView()
.appActivityIndicatorStyle(.small)
} else {
Image(systemName: "magnifyingglass")
}
}
.disabled(isDiscoveringSourceCandidates)
.help("Find Minecraft Sources")
}
ToolbarItem {
Button(action: addSourceAction) {
Image(systemName: "folder.badge.plus")
}
.help("Add Source Folder")
}
ToolbarItem {
Button(action: addDeviceSourceAction) {
Image(systemName: "iphone.gen3")
}
.help("Add Connected Device Source")
}
}
}
@ViewBuilder
private func sourceSectionRows(for source: MinecraftSource) -> some View {
let sourceFilters = filters(source)
SourceHeaderRow(
source: source,
isSelected: selection == .source(sourceID: source.id),
onSelect: {
selection = .source(sourceID: source.id)
}
)
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
.contextMenu {
Button("Rescan \"\(source.displayName)\"") {
rescanSourceAction(source)
}
Divider()
Button("Remove \"\(source.displayName)\"", role: .destructive) {
removeSourceAction(source)
}
}
ForEach(sourceFilters) { filter in
SidebarFilterRow(filter: filter, isIndented: true)
.tag(filter.selection as SidebarSelection?)
}
}
@ViewBuilder
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
ConnectedDeviceRow(
entry: entry,
onSelect: {
selection = .connectedDevice(deviceID: entry.id)
},
addAction: entry.hasMinecraftContainer ? {
addConnectedDeviceAction(entry)
} : nil
)
.tag(SidebarSelection.connectedDevice(deviceID: entry.id) as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
}
}
private struct SourceCandidateRow: View {
let candidate: SourceCandidate
let onSelect: () -> Void
let addAction: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: symbolName)
.foregroundStyle(.secondary)
.frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
Text(candidate.displayName)
.lineLimit(1)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Button(action: addAction) {
Text("Add")
}
.appMiniProminentButton()
.help("Add Source")
}
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.padding(.vertical, 4)
}
private var symbolName: String {
switch candidate.edition {
case .bedrock:
return "folder"
case .java:
return "curlybraces"
}
}
private var subtitle: String {
let editionName = candidate.edition == .java ? "Java" : "Bedrock"
return "\(editionName) - \(candidate.sourceRootURL.lastPathComponent)"
}
}
private struct SidebarFilterRow: View {
let filter: SidebarFilter
let isIndented: Bool
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)
}
.padding(.leading, isIndented ? 16 : 0)
}
}
private struct SidebarSourcesSectionHeaderView: View {
let title: String
var body: some View {
Text(title)
}
}
private struct SourceHeaderRow: View {
let source: MinecraftSource
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: headerSymbolName)
.foregroundStyle(.secondary)
Text(source.displayName)
.lineLimit(1)
Spacer(minLength: 8)
HStack(spacing: 8) {
if let availabilityBadgeText {
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
}
if let connection {
SourceConnectionBadge(connection: connection)
}
if showsStatusAccessory {
statusAccessory
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background {
if isSelected {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.accentColor.opacity(0.18))
}
}
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
private var connection: DeviceConnection? {
guard source.availability == .available else {
return nil
}
guard case .connectedDevice(let device, _) = source.origin else {
return nil
}
return device.connection
}
private var headerSymbolName: String {
switch source.origin {
case .localFolder, .javaLocalFolder:
return "folder"
case .connectedDevice:
return "iphone.gen3"
}
}
private var availabilityBadgeText: String? {
if source.isOfflineCached {
return "Cached"
}
switch source.availability {
case .limited:
return "Limited"
case .unavailable, .disconnected:
return "Offline"
case .available, .unknown:
return nil
}
}
private var availabilityBadgeEmphasis: Bool {
source.availability == .limited
}
private var showsStatusAccessory: Bool {
source.isScanning
}
@ViewBuilder
private var statusAccessory: some View {
if source.isScanning {
if let scanProgress = source.scanProgress {
CircularScanProgressView(progress: scanProgress)
} else {
ProgressView()
.appActivityIndicatorStyle(.small)
}
}
}
}
private struct SourceConnectionBadge: View {
let connection: DeviceConnection
var body: some View {
Image(systemName: symbolName)
.appCapsuleLabelStyle(.sidebarSubtle)
.help(helpText)
.accessibilityLabel(helpText)
}
private var symbolName: String {
switch connection {
case .usb:
return "cable.connector"
case .network:
return "wifi"
}
}
private var helpText: String {
switch connection {
case .usb:
return "USB"
case .network:
return "Network"
}
}
}
private struct SourceAvailabilityBadge: View {
let text: String
let emphasis: Bool
var body: some View {
Text(text)
.appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle)
}
}
private struct CircularScanProgressView: View {
let progress: Double
var body: some View {
ZStack {
Circle()
.stroke(.secondary.opacity(0.18), lineWidth: 3)
Circle()
.trim(from: 0, to: max(0.02, min(progress, 1)))
.stroke(
Color.appAccent,
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.rotationEffect(.degrees(-90))
}
.frame(width: 18, height: 18)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Scan progress")
.accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
}
}
private struct ConnectedDeviceRow: View {
let entry: ConnectedDeviceSidebarEntry
let onSelect: () -> Void
let addAction: (() -> Void)?
var body: some View {
HStack(alignment: .top, spacing: 10) {
ConnectedDeviceTransportIcon(
baseSymbolName: iconName,
connection: entry.device.connection,
tint: iconColor
)
VStack(alignment: .leading, spacing: 4) {
Text(entry.device.name)
.appTextStyle(.rowTitle)
.foregroundStyle(titleColor)
Text(statusText)
.appTextStyle(.supportingCompact)
}
Spacer(minLength: 12)
if let addAction {
Button("Add") {
addAction()
}
.appMiniProminentButton()
}
}
.opacity(addAction == nil ? 0.68 : 1)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
private var iconName: String {
if entry.hasMinecraftContainer {
return "iphone.gen3"
}
switch entry.device.trustState {
case .trusted:
return "iphone.slash"
case .locked, .untrusted:
return "lock.iphone"
case .unavailable:
return "iphone.gen3.slash"
}
}
private var iconColor: Color {
entry.hasMinecraftContainer ? .appAccent : .secondary
}
private var titleColor: Color {
addAction == nil ? .secondary : .primary
}
private var statusText: String {
if let errorDescription = entry.discoveryErrorDescription, !errorDescription.isEmpty {
return errorDescription
}
switch entry.device.trustState {
case .trusted:
if entry.hasMinecraftContainer {
return "Ready to add Minecraft library"
}
return "No Minecraft source found"
case .locked:
return "Unlock this device to inspect apps"
case .untrusted:
return "Trust this device to inspect apps"
case .unavailable:
return "Device unavailable"
}
}
}
private struct ConnectedDeviceTransportIcon: View {
let baseSymbolName: String
let connection: DeviceConnection
let tint: Color
var body: some View {
ZStack(alignment: .bottomTrailing) {
Image(systemName: baseSymbolName)
.font(.system(size: 22, weight: .medium))
.foregroundStyle(tint)
.frame(width: 28, height: 28)
Image(systemName: badgeSymbolName)
.appTransportBadgeBubble()
.offset(x: 4, y: 4)
}
.help(helpText)
.accessibilityElement(children: .ignore)
.accessibilityLabel(helpText)
}
private var badgeSymbolName: String {
switch connection {
case .usb:
return "cable.connector"
case .network:
return "wifi"
}
}
private var helpText: String {
switch connection {
case .usb:
return "Connected by USB"
case .network:
return "Connected by Network"
}
}
}
#if DEBUG
struct SidebarColumnViews_Previews: PreviewProvider {
static var previews: some View {
SidebarColumnPreviewContainer()
}
}
#endif