diff --git a/README.md b/README.md index 73518f6..530ba03 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Validated with automated tests, debug and release builds, packaged-app code-sign - Live process list with CPU, memory, disk I/O, and network usage - Cached native application icons throughout process groups, app history, startup apps, users, services, properties, and dependency views - Search and sortable process columns, including live Disk and Network rates +- Drag-resizable, persisted Processes columns with memory hover details showing both resident bytes and physical-memory percentage - Column sorting automatically flattens process categories for a true global order; the header menu can restore Group by type - Expandable application groups plus an advanced Details table with persisted optional columns for threads, open handles, architecture, priority/nice, parent PID, CPU time, and other diagnostics - Collapsible Windows-style Apps, Background processes, and macOS processes categories diff --git a/Sources/MacTaskManager/ProcessViews.swift b/Sources/MacTaskManager/ProcessViews.swift index 87c773d..51995ab 100644 --- a/Sources/MacTaskManager/ProcessViews.swift +++ b/Sources/MacTaskManager/ProcessViews.swift @@ -15,6 +15,40 @@ private enum ResourceDisplayMode: String, CaseIterable { case percentages = "Percentages" } +private let processNumericTextInset: CGFloat = 7 + +private enum ProcessColumn: String, CaseIterable, Codable { + case name, pid, status, cpu, memory, disk, network + + var defaultWidth: CGFloat { + switch self { + case .name: 300 + case .pid: 70 + case .status: 80 + case .cpu: 86 + case .memory: 110 + case .disk, .network: 88 + } + } + + var minimumWidth: CGFloat { + switch self { + case .name: 160 + case .pid: 54 + case .status: 68 + case .cpu: 64 + case .memory: 86 + case .disk, .network: 76 + } + } + + var maximumWidth: CGFloat { self == .name ? 720 : 260 } + + static var defaultWidths: [ProcessColumn: CGFloat] { + Dictionary(uniqueKeysWithValues: allCases.map { ($0, $0.defaultWidth) }) + } +} + private enum ProcessCategory: String, CaseIterable, Identifiable { case apps = "Apps" case background = "Background processes" @@ -35,6 +69,10 @@ struct ProcessesView: View { @AppStorage("processSortAscending") private var ascending = false @AppStorage("processGroupByType") private var groupByType = true @AppStorage("processResourceDisplayMode") private var resourceDisplayMode: ResourceDisplayMode = .values + @AppStorage("processColumnWidths") private var columnWidthsStorage = "" + @State private var columnWidths = ProcessColumn.defaultWidths + @State private var groupsFrozenForResize: [ProcessGroup]? + @State private var isResizingColumn = false @State private var terminationRequest: TerminationRequest? @State private var groupTerminationRequest: ProcessGroup? @State private var inspectedProcess: ProcessRecord? @@ -65,6 +103,8 @@ struct ProcessesView: View { } } + private var displayedGroups: [ProcessGroup] { groupsFrozenForResize ?? grouped } + var body: some View { VStack(spacing: 0) { PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") { @@ -74,45 +114,62 @@ struct ProcessesView: View { } } ResourceSummaryBar(snapshot: monitor.snapshot) - ProcessTableHeader( - sort: $sort, - ascending: $ascending, - groupByType: $groupByType, - resourceDisplayMode: $resourceDisplayMode - ) - ScrollView { - LazyVStack(spacing: 0) { - if groupByType { - ForEach(ProcessCategory.allCases) { category in - let categoryGroups = grouped.filter { $0.category == category } - if !categoryGroups.isEmpty { - ProcessCategoryHeader( - category: category, - count: categoryGroups.count, - isExpanded: !collapsedCategories.contains(category) || !searchText.isEmpty - ) { - if collapsedCategories.contains(category) { - collapsedCategories.remove(category) - } else { - collapsedCategories.insert(category) + ScrollView(.horizontal) { + VStack(spacing: 0) { + ProcessTableHeader( + sort: $sort, + ascending: $ascending, + groupByType: $groupByType, + resourceDisplayMode: $resourceDisplayMode, + columnWidths: $columnWidths, + onResizeStarted: { + isResizingColumn = true + if groupsFrozenForResize == nil { groupsFrozenForResize = grouped } + }, + onResizeEnded: { + persistColumnWidths() + isResizingColumn = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + if !isResizingColumn { groupsFrozenForResize = nil } + } + } + ) + ScrollView { + LazyVStack(spacing: 0) { + if groupByType { + ForEach(ProcessCategory.allCases) { category in + let categoryGroups = displayedGroups.filter { $0.category == category } + if !categoryGroups.isEmpty { + ProcessCategoryHeader( + category: category, + count: categoryGroups.count, + isExpanded: !collapsedCategories.contains(category) || !searchText.isEmpty + ) { + if collapsedCategories.contains(category) { + collapsedCategories.remove(category) + } else { + collapsedCategories.insert(category) + } + } + + if !collapsedCategories.contains(category) || !searchText.isEmpty { + ForEach(categoryGroups) { group in + processGroup(group) + } + } } } - - if !collapsedCategories.contains(category) || !searchText.isEmpty { - ForEach(categoryGroups) { group in - processGroup(group) - } + } else { + ForEach(displayedGroups) { group in + processGroup(group) } } } - } else { - ForEach(grouped) { group in - processGroup(group) - } } } - .padding(.leading, 10) + .frame(width: processTableWidth, alignment: .leading) } + .frame(maxWidth: .infinity, maxHeight: .infinity) Divider() HStack { UpdateStatus() @@ -162,6 +219,29 @@ struct ProcessesView: View { .sheet(item: $inspectedProcess) { process in ProcessPropertiesView(process: process) } + .onAppear(perform: restoreColumnWidths) + } + + private var processTableWidth: CGFloat { + ProcessColumn.allCases.reduce(0) { $0 + (columnWidths[$1] ?? $1.defaultWidth) } + + CGFloat(ProcessColumn.allCases.count - 1) * 12 + + 32 + } + + private func restoreColumnWidths() { + guard let data = columnWidthsStorage.data(using: .utf8), + let stored = try? JSONDecoder().decode([String: Double].self, from: data) else { return } + for column in ProcessColumn.allCases { + guard let value = stored[column.rawValue] else { continue } + columnWidths[column] = min(column.maximumWidth, max(column.minimumWidth, CGFloat(value))) + } + } + + private func persistColumnWidths() { + let stored = Dictionary(uniqueKeysWithValues: columnWidths.map { ($0.key.rawValue, Double($0.value)) }) + guard let data = try? JSONEncoder().encode(stored), + let value = String(data: data, encoding: .utf8) else { return } + columnWidthsStorage = value } private func expansionBinding(for group: ProcessGroup) -> Binding { @@ -178,41 +258,29 @@ struct ProcessesView: View { private func processGroup(_ group: ProcessGroup) -> some View { if group.processes.count > 1 { VStack(spacing: 0) { - HStack(spacing: 0) { - Button { + ProcessGroupRow( + group: group, + selected: selectedGroupID == group.id, + displayMode: resourceDisplayMode, + columnWidths: columnWidths, + isExpanded: expansionBinding(for: group), + onToggleExpansion: { let binding = expansionBinding(for: group) binding.wrappedValue.toggle() - } label: { - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .rotationEffect(.degrees(expansionBinding(for: group).wrappedValue ? 90 : 0)) - .frame(width: 16, height: 48) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .help(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)") - .accessibilityLabel(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)") - - ProcessGroupRow( + ) + .contentShape(Rectangle()) + .onTapGesture { + selectedGroupID = group.id + selectedPID = nil + } + .contextMenu { + ProcessGroupContextMenu( group: group, - selected: selectedGroupID == group.id, - displayMode: resourceDisplayMode, - leadingPadding: 0 + onTerminate: { groupTerminationRequest = group }, + onShowDetails: onShowDetails, + onShowProperties: { inspectedProcess = $0 } ) - .contentShape(Rectangle()) - .onTapGesture { - selectedGroupID = group.id - selectedPID = nil - } - .contextMenu { - ProcessGroupContextMenu( - group: group, - onTerminate: { groupTerminationRequest = group }, - onShowDetails: onShowDetails, - onShowProperties: { inspectedProcess = $0 } - ) - } } if expansionBinding(for: group).wrappedValue { @@ -227,7 +295,13 @@ struct ProcessesView: View { } private func processRow(_ process: ProcessRecord, isChild: Bool) -> some View { - ProcessRow(process: process, selected: selectedPID == process.pid, isChild: isChild, displayMode: resourceDisplayMode) + ProcessRow( + process: process, + selected: selectedPID == process.pid, + isChild: isChild, + displayMode: resourceDisplayMode, + columnWidths: columnWidths + ) .contentShape(Rectangle()) .onTapGesture { selectedPID = process.pid @@ -362,16 +436,24 @@ private struct ProcessTableHeader: View { @Binding var ascending: Bool @Binding var groupByType: Bool @Binding var resourceDisplayMode: ResourceDisplayMode + @Binding var columnWidths: [ProcessColumn: CGFloat] + let onResizeStarted: () -> Void + let onResizeEnded: () -> Void var body: some View { HStack(spacing: 12) { - header("Name", field: .name).frame(maxWidth: .infinity, alignment: .leading) - header("PID", field: .pid).frame(width: 70, alignment: .trailing) - Text("Status").frame(width: 80, alignment: .leading) - header("CPU", field: .cpu).frame(width: 86, alignment: .trailing) - header("Memory", field: .memory).frame(width: 110, alignment: .trailing) - header("Disk", field: .disk).frame(width: 88, alignment: .trailing) - header("Network", field: .network).frame(width: 88, alignment: .trailing) + resizable(.name, alignment: .leading) { header("Name", field: .name, column: .name) } + resizable(.pid, alignment: .leading) { header("PID", field: .pid, column: .pid) } + resizable(.status, alignment: .leading) { + Text("Status") + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, processNumericTextInset) + } + resizable(.cpu, alignment: .leading) { header("CPU", field: .cpu, column: .cpu) } + resizable(.memory, alignment: .leading) { header("Memory", field: .memory, column: .memory) } + resizable(.disk, alignment: .leading) { header("Disk", field: .disk, column: .disk) } + resizable(.network, alignment: .leading) { header("Network", field: .network, column: .network) } } .font(.caption.weight(.medium)) .foregroundStyle(.secondary) @@ -394,17 +476,100 @@ private struct ProcessTableHeader: View { } } - private func header(_ title: String, field: ProcessSort) -> some View { - Button { + private func resizable( + _ column: ProcessColumn, + alignment: Alignment, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + ResizableProcessHeaderCell( + width: Binding( + get: { columnWidths[column] ?? column.defaultWidth }, + set: { columnWidths[column] = $0 } + ), + minimumWidth: column.minimumWidth, + maximumWidth: column.maximumWidth, + alignment: alignment, + onResizeStarted: onResizeStarted, + onResizeEnded: onResizeEnded, + content: content + ) + } + + private func header( + _ title: String, + field: ProcessSort, + column: ProcessColumn + ) -> some View { + HStack(spacing: 0) { + Text(title).lineLimit(1) + Spacer(minLength: 4) + if sort == field { + Color.clear.frame(width: 6, height: 0) + Image(systemName: ascending ? "chevron.up" : "chevron.down") + .frame(width: 10) + } + Color.clear.frame(width: processNumericTextInset, height: 0) + } + .frame(width: columnWidths[column] ?? column.defaultWidth) + .contentShape(Rectangle()) + .onTapGesture { groupByType = false if sort == field { ascending.toggle() } else { sort = field; ascending = false } - } label: { - HStack(spacing: 3) { - Text(title) - if sort == field { Image(systemName: ascending ? "chevron.up" : "chevron.down") } - } } - .buttonStyle(.plain) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(title) + .accessibilityValue(sort == field ? (ascending ? "Sorted ascending" : "Sorted descending") : "Not sorted") + } +} + +private struct ResizableProcessHeaderCell: View { + @Binding var width: CGFloat + let minimumWidth: CGFloat + let maximumWidth: CGFloat + let alignment: Alignment + let onResizeStarted: () -> Void + let onResizeEnded: () -> Void + @ViewBuilder let content: () -> Content + @State private var dragStartWidth: CGFloat? + @State private var proposedWidth: CGFloat? + @State private var isHandleHovered = false + + var body: some View { + content() + .frame(width: width, alignment: alignment) + .clipped() + .overlay(alignment: .trailing) { + ZStack { + Rectangle() + .fill(isHandleHovered || dragStartWidth != nil ? Color.accentColor : Color.secondary.opacity(0.25)) + .frame(width: isHandleHovered || dragStartWidth != nil ? 2 : 1, height: 22) + } + .frame(width: 10, height: 34) + .offset(x: (proposedWidth ?? width) - width + 5) + .contentShape(Rectangle()) + .onHover { hovering in + isHandleHovered = hovering + (hovering ? NSCursor.resizeLeftRight : NSCursor.arrow).set() + } + .highPriorityGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if dragStartWidth == nil { + dragStartWidth = width + onResizeStarted() + } + let proposed = (dragStartWidth ?? width) + value.translation.width + proposedWidth = min(maximumWidth, max(minimumWidth, proposed)) + } + .onEnded { _ in + if let proposedWidth { width = proposedWidth } + proposedWidth = nil + dragStartWidth = nil + onResizeEnded() + } + ) + .help("Drag to resize column") + } } } @@ -414,6 +579,7 @@ private struct ProcessRow: View { let selected: Bool let isChild: Bool let displayMode: ResourceDisplayMode + let columnWidths: [ProcessColumn: CGFloat] private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() } @@ -424,25 +590,33 @@ private struct ProcessRow: View { Image(systemName: "arrow.turn.down.right") .font(.caption) .foregroundStyle(.tertiary) - .frame(width: 12) + .frame(width: 16) + } else { + Color.clear.frame(width: 16) } ProcessIcon(name: process.displayName, path: process.executablePath) VStack(alignment: .leading, spacing: 2) { - Text(process.displayName).lineLimit(1) - Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Text(process.displayName).lineLimit(1).truncationMode(.tail) + Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail) } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) - Text("\(process.pid)").monospacedDigit().frame(width: 70, alignment: .trailing) - Text(process.state.hasPrefix("R") ? "Running" : "").frame(width: 80, alignment: .leading) + .frame(width: width(.name), alignment: .leading) + .clipped() + Text("\(process.pid)").monospacedDigit().lineLimit(1).minimumScaleFactor(0.75) + .padding(.trailing, processNumericTextInset) + .frame(width: width(.pid), alignment: .trailing).clipped() + Text(process.state.hasPrefix("R") ? "Running" : "").lineLimit(1).truncationMode(.tail) + .frame(width: width(.status), alignment: .leading).clipped() HeatCell(text: Formatters.percent(process.cpu), intensity: process.cpu / 100) - .frame(width: 86, alignment: .trailing) + .frame(width: width(.cpu), alignment: .trailing) HeatCell(text: memoryText, intensity: process.memoryPercent / 30) - .frame(width: 110, alignment: .trailing) + .frame(width: width(.memory), alignment: .trailing) + .help(memoryHelp) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) - .frame(width: 88, alignment: .trailing) + .frame(width: width(.disk), alignment: .trailing) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) - .frame(width: 88, alignment: .trailing) + .frame(width: width(.network), alignment: .trailing) } .font(.callout) .padding(.horizontal, 16) @@ -455,6 +629,14 @@ private struct ProcessRow: View { displayMode == .percentages ? Formatters.percent(process.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(process.residentBytes)) } + private var memoryHelp: String { + "Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))) • \(Formatters.percent(process.memoryPercent)) of physical memory" + } + + private func width(_ column: ProcessColumn) -> CGFloat { + columnWidths[column] ?? column.defaultWidth + } + private var diskText: String { displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal) } @@ -474,35 +656,53 @@ private struct ProcessGroupRow: View { let group: ProcessGroup let selected: Bool let displayMode: ResourceDisplayMode - let leadingPadding: CGFloat + let columnWidths: [ProcessColumn: CGFloat] + @Binding var isExpanded: Bool + let onToggleExpansion: () -> Void private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) } var body: some View { HStack(spacing: 12) { HStack(spacing: 10) { + Button(action: onToggleExpansion) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .frame(width: 16, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)") + .accessibilityLabel(isExpanded ? "Collapse \(group.name)" : "Expand \(group.name)") ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath) VStack(alignment: .leading, spacing: 2) { - Text("\(group.name) (\(group.processes.count))").lineLimit(1) - Text("Application group").font(.caption).foregroundStyle(.secondary) + Text("\(group.name) (\(group.processes.count))").lineLimit(1).truncationMode(.tail) + Text("Application group").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail) } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) - Text("—").frame(width: 70, alignment: .trailing).foregroundStyle(.secondary) + .frame(width: width(.name), alignment: .leading) + .clipped() + Text("—").lineLimit(1) + .padding(.trailing, processNumericTextInset) + .frame(width: width(.pid), alignment: .trailing).foregroundStyle(.secondary).clipped() Text(group.processes.contains(where: { $0.state.hasPrefix("R") }) ? "Running" : "") - .frame(width: 80, alignment: .leading) + .lineLimit(1).truncationMode(.tail) + .frame(width: width(.status), alignment: .leading).clipped() HeatCell(text: Formatters.percent(group.cpu), intensity: group.cpu / 100) - .frame(width: 86, alignment: .trailing) + .frame(width: width(.cpu), alignment: .trailing) HeatCell(text: memoryText, intensity: group.memoryPercent / 30) - .frame(width: 110, alignment: .trailing) + .frame(width: width(.memory), alignment: .trailing) + .help(memoryHelp) HeatCell(text: diskText, intensity: activity.diskTotal / 10_000_000) - .frame(width: 88, alignment: .trailing) + .frame(width: width(.disk), alignment: .trailing) HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000) - .frame(width: 88, alignment: .trailing) + .frame(width: width(.network), alignment: .trailing) } .font(.callout.weight(.medium)) - .padding(.leading, leadingPadding) - .padding(.trailing, 16) + .padding(.horizontal, 16) .frame(height: 48) .background(selected ? Color.accentColor.opacity(0.18) : Color.clear) .overlay(alignment: .bottom) { Divider().opacity(0.45) } @@ -515,6 +715,14 @@ private struct ProcessGroupRow: View { displayMode == .percentages ? Formatters.percent(group.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(group.residentBytes)) } + private var memoryHelp: String { + "Resident memory: \(Formatters.bytes.string(fromByteCount: Int64(group.residentBytes))) • \(Formatters.percent(group.memoryPercent)) of physical memory" + } + + private func width(_ column: ProcessColumn) -> CGFloat { + columnWidths[column] ?? column.defaultWidth + } + private var diskText: String { displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal) } @@ -610,9 +818,13 @@ private struct HeatCell: View { var body: some View { Text(text) .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.72) + .truncationMode(.tail) .padding(.vertical, 9) - .padding(.horizontal, 7) + .padding(.horizontal, processNumericTextInset) .frame(maxWidth: .infinity, alignment: .trailing) + .clipped() .background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32))) .clipShape(RoundedRectangle(cornerRadius: 4)) } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index dbb168c..2bd473e 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -3,8 +3,12 @@ set -euo pipefail PROJECT_DIR="${0:A:h:h}" APP_DIR="$PROJECT_DIR/dist/Task Manager.app" -CONTENTS_DIR="$APP_DIR/Contents" ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon" +STAGING_ROOT="$(mktemp -d /tmp/mactaskmanager-build.XXXXXX)" +STAGED_APP="$STAGING_ROOT/Task Manager.app" +CONTENTS_DIR="$STAGED_APP/Contents" + +trap '/bin/rm -rf -- "$STAGING_ROOT"' EXIT cd "$PROJECT_DIR" swift build -c release @@ -23,5 +27,12 @@ if [[ -d "$ICON_SOURCE" ]]; then --output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null fi -codesign --force --deep --sign - "$APP_DIR" >/dev/null +# Finder and cloud-sync metadata can make an otherwise valid app bundle +# unsignable when rebuilding in place. +xattr -cr "$STAGED_APP" +codesign --force --deep --sign - "$STAGED_APP" >/dev/null +codesign --verify --deep --strict "$STAGED_APP" + +mkdir -p "$PROJECT_DIR/dist" +ditto --norsrc "$STAGED_APP" "$APP_DIR" print "Built $APP_DIR"