diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index d93afab..0ddbbe5 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -13,118 +13,129 @@ struct DownloadTable: View { @State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)] @State private var pendingDeleteItems: Set? - - var sortedItems: [DownloadItem] { - items.sorted(using: sortOrder) - } + @State private var sortedItems: [DownloadItem] = [] var body: some View { VStack(spacing: 0) { HStack { Text(title) - .font(.headline) + .font(.title2) + .fontWeight(.semibold) Text("\(items.count)") - .font(.caption) + .font(.subheadline) .foregroundStyle(.secondary) - .padding(.horizontal, 7) - .padding(.vertical, 3) + .padding(.horizontal, 8) + .padding(.vertical, 4) .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 5)) + .clipShape(Capsule()) Spacer() } - .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.horizontal, 16) + .padding(.vertical, 12) - Table(sortedItems, selection: $selection, sortOrder: $sortOrder) { - TableColumn("File Name", value: \.fileName) { item in - HStack(alignment: .top, spacing: 8) { - Image(systemName: item.category.symbolName) - .font(.title3) - .foregroundStyle(categoryColor(for: item.category)) - .frame(width: 22) - Text(item.fileName) - .font(.headline) - .lineLimit(1) - .truncationMode(.tail) - .allowsHitTesting(false) + if items.isEmpty { + ContentUnavailableView( + "No Downloads", + systemImage: "arrow.down.circle", + description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Table(sortedItems, selection: $selection, sortOrder: $sortOrder) { + TableColumn("File Name", value: \.fileName) { item in + HStack(alignment: .top, spacing: 8) { + Image(systemName: item.category.symbolName) + .font(.title3) + .foregroundStyle(categoryColor(for: item.category)) + .frame(width: 22) + Text(item.fileName) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + .allowsHitTesting(false) + } + .draggable(item.id.uuidString) } - .draggable(item.id.uuidString) - } - .width(min: 200, ideal: 340) + .width(min: 200, ideal: 340) - TableColumn("Size", value: \.sortableSize) { item in - if let size = item.sizeBytes, size > 0 { - Text(ByteFormatter.string(size)) - .monospacedDigit() - .lineLimit(1) - .truncationMode(.tail) - } else if item.bytesText != "-" && !item.bytesText.isEmpty { - Text(item.bytesText) - .monospacedDigit() - .lineLimit(1) - .truncationMode(.tail) - } else { - Text("Unknown") - .monospacedDigit() + TableColumn("Size", value: \.sortableSize) { item in + if let size = item.sizeBytes, size > 0 { + Text(ByteFormatter.string(size)) + .monospacedDigit() + .lineLimit(1) + .truncationMode(.tail) + } else if item.bytesText != "-" && !item.bytesText.isEmpty { + Text(item.bytesText) + .monospacedDigit() + .lineLimit(1) + .truncationMode(.tail) + } else { + Text("Unknown") + .monospacedDigit() + .lineLimit(1) + .truncationMode(.tail) + } + } + .width(min: 80, ideal: 100) + + TableColumn("Status", value: \.status.rawValue) { item in + combinedStatusCell(for: item) + } + .width(min: 160, ideal: 200) + + TableColumn("Speed", value: \.displaySpeedText) { item in + if item.status == .downloading { + formattedSpeedCell(for: item.displaySpeedText) + } else { + Text("-") + .foregroundStyle(.tertiary) + } + } + .width(min: 80, ideal: 100) + + TableColumn("ETA", value: \.displayETAText) { item in + if item.status == .downloading { + formattedETACell(for: item.displayETAText) + } else { + Text("-") + .foregroundStyle(.tertiary) + } + } + .width(min: 80, ideal: 100) + + TableColumn("Date Added", value: \.createdAt) { item in + Text(formatted(item.createdAt)) .lineLimit(1) .truncationMode(.tail) } + .width(min: 100, ideal: 155) } - .width(min: 80, ideal: 100) - - TableColumn("Progress", value: \.progress) { item in - progressBarCell(for: item) - } - .width(min: 100, ideal: 115) - - TableColumn("Status", value: \.status.rawValue) { item in - statusCell(for: item) - } - .width(min: 115, ideal: 170) - - TableColumn("Speed", value: \.displaySpeedText) { item in - Text(item.displaySpeedText) - .lineLimit(1) - .truncationMode(.tail) - } - .width(min: 70, ideal: 90) - - TableColumn("ETA", value: \.displayETAText) { item in - Text(item.displayETAText) - .lineLimit(1) - .truncationMode(.tail) - } - .width(min: 70, ideal: 90) - - TableColumn("Date Added", value: \.createdAt) { item in - Text(formatted(item.createdAt)) - .lineLimit(1) - .truncationMode(.tail) - } - .width(min: 100, ideal: 155) - } - .environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight) - .animation(.default, value: sortedItems) - .contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in - rowContextMenu(for: itemIDs) - } primaryAction: { itemIDs in - let targetItems = controller.downloads.filter { itemIDs.contains($0.id) } - for target in targetItems { - if target.status == .completed { - openFile(target) + .environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight) + .animation(.default, value: sortedItems) + .contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in + rowContextMenu(for: itemIDs) + } primaryAction: { itemIDs in + let targetItems = controller.downloads.filter { itemIDs.contains($0.id) } + for target in targetItems { + if target.status == .completed { + openFile(target) + } } } } - .overlay { - if items.isEmpty { - ContentUnavailableView( - "No Downloads", - systemImage: "arrow.down.circle", - description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.") - ) - } + } + .onAppear { sortedItems = items.sorted(using: sortOrder) } + .onChange(of: items) { _, newItems in + if newItems.count != sortedItems.count { + sortedItems = newItems.sorted(using: sortOrder) + } else { + let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) }) + sortedItems = sortedItems.compactMap { itemsDict[$0.id] } } } + .onChange(of: sortOrder) { _, newOrder in + sortedItems = items.sorted(using: newOrder) + } .confirmationDialog( "Delete Download", isPresented: Binding( @@ -165,19 +176,81 @@ struct DownloadTable: View { } @ViewBuilder - private func statusCell(for item: DownloadItem) -> some View { - let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines) - if item.status == .downloading, !message.isEmpty { - Text(message) - .lineLimit(1) - .truncationMode(.tail) + private func combinedStatusCell(for item: DownloadItem) -> some View { + if item.status == .completed { + Text("Completed") + .foregroundStyle(.green) + .fontWeight(.medium) } else { - Text(item.status.rawValue) + HStack(spacing: 8) { + ProgressView(value: item.progress) + .progressViewStyle(.linear) + .tint(statusColor(for: item.status)) + + if item.status == .downloading { + Text(item.progress.formatted(.percent.precision(.fractionLength(0)))) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 35, alignment: .trailing) + } else { + Text(item.status.rawValue.capitalized) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + } + } + } + + private func parseSpeed(_ text: String) -> [String] { + var display = text + + if let index = display.firstIndex(where: { $0.isLetter }) { + if display.distance(from: display.startIndex, to: index) > 0 { + let prevIndex = display.index(before: index) + if display[prevIndex] != " " { + display.insert(" ", at: index) + } + } + } + return display.split(separator: " ", maxSplits: 1).map(String.init) + } + + @ViewBuilder + private func formattedSpeedCell(for text: String) -> some View { + let components = parseSpeed(text) + if components.count == 2 { + HStack(alignment: .firstTextBaseline, spacing: 1) { + Text(components[0]) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) + Text(components[1]) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .truncationMode(.tail) + } else { + Text(components.joined(separator: " ")) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) } } + @ViewBuilder + private func formattedETACell(for text: String) -> some View { + Text(text) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + @ViewBuilder private func rowContextMenu(for itemIDs: Set) -> some View { let targetItems = controller.downloads.filter { itemIDs.contains($0.id) } @@ -303,25 +376,7 @@ struct DownloadTable: View { } } - @ViewBuilder - private func progressBarCell(for item: DownloadItem) -> some View { - if item.status == .completed { - Text("Completed") - .foregroundStyle(.green) - .lineLimit(1) - .truncationMode(.tail) - } else { - HStack(spacing: 4) { - ProgressView(value: item.progress) - .progressViewStyle(.linear) - .tint(statusColor(for: item.status)) - - Text(item.progress.formatted(.percent.precision(.fractionLength(0)))) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .frame(width: 35, alignment: .trailing) - } - } - } + private func formatted(_ date: Date?) -> String { guard let date else { return "-" } diff --git a/Sources/Firelink/Settings/SettingsContainer.swift b/Sources/Firelink/Settings/SettingsContainer.swift index a904191..fc2d462 100644 --- a/Sources/Firelink/Settings/SettingsContainer.swift +++ b/Sources/Firelink/Settings/SettingsContainer.swift @@ -1,6 +1,32 @@ import AppKit import SwiftUI +enum SettingsSidebarFilter: String, CaseIterable, Hashable { + case downloads = "Downloads" + case lookAndFeel = "Look and feel" + case network = "Network" + case locations = "Locations" + case siteLogins = "Site Logins" + case power = "Power" + case engine = "Engine" + case integration = "Integrations" + case about = "About" + + var symbolName: String { + switch self { + case .downloads: "arrow.down.circle" + case .lookAndFeel: "paintpalette" + case .network: "network" + case .locations: "folder" + case .siteLogins: "key.fill" + case .power: "moon.zzz" + case .engine: "terminal" + case .integration: "puzzlepiece.extension" + case .about: "info.circle" + } + } +} + struct SettingsPaneContainer: View { @AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads diff --git a/Sources/Firelink/SidebarView.swift b/Sources/Firelink/SidebarView.swift index bea13fb..5323c6f 100644 --- a/Sources/Firelink/SidebarView.swift +++ b/Sources/Firelink/SidebarView.swift @@ -21,31 +21,6 @@ enum DownloadSidebarFilter: Hashable { } } -enum SettingsSidebarFilter: String, CaseIterable, Hashable { - case downloads = "Downloads" - case lookAndFeel = "Look and feel" - case network = "Network" - case locations = "Locations" - case siteLogins = "Site Logins" - case power = "Power" - case engine = "Engine" - case integration = "Integrations" - case about = "About" - - var symbolName: String { - switch self { - case .downloads: "arrow.down.circle" - case .lookAndFeel: "paintpalette" - case .network: "network" - case .locations: "folder" - case .siteLogins: "key.fill" - case .power: "moon.zzz" - case .engine: "terminal" - case .integration: "puzzlepiece.extension" - case .about: "info.circle" - } - } -} enum SidebarSelection: Hashable { case downloads(DownloadSidebarFilter) @@ -57,6 +32,7 @@ enum SidebarSelection: Hashable { struct SidebarView: View { @EnvironmentObject private var controller: DownloadController + @Environment(\.controlActiveState) private var controlActiveState @Binding var selection: SidebarSelection @State private var queueBeingRenamed: DownloadQueue? @State private var queueBeingRemoved: DownloadQueue? @@ -95,6 +71,7 @@ struct SidebarView: View { selection = .queue(queue.id) } label: { Label("Add new queue", systemImage: "plus") + .foregroundStyle(.secondary) } .buttonStyle(.plain) } @@ -121,8 +98,8 @@ struct SidebarView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .background(selection == .settings ? Color.accentColor : Color.clear) - .foregroundStyle(selection == .settings ? Color.white : Color.primary) + .background(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.accentColor : Color.secondary.opacity(0.5)) : Color.clear) + .foregroundStyle(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.white : Color.primary) : Color.primary) .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.horizontal, 8) .padding(.vertical, 8) @@ -163,7 +140,7 @@ struct SidebarView: View { Button("Delete Queue", role: .destructive) { controller.removeQueue(id: queue.id) if selection == .queue(queue.id) { - selection = .downloads(.unfinished) + selection = .downloads(.all) } queueBeingRemoved = nil }