|
|
|
@@ -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,17 +114,31 @@ struct ProcessesView: View {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ResourceSummaryBar(snapshot: monitor.snapshot)
|
|
|
|
|
ScrollView(.horizontal) {
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
ProcessTableHeader(
|
|
|
|
|
sort: $sort,
|
|
|
|
|
ascending: $ascending,
|
|
|
|
|
groupByType: $groupByType,
|
|
|
|
|
resourceDisplayMode: $resourceDisplayMode
|
|
|
|
|
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 = grouped.filter { $0.category == category }
|
|
|
|
|
let categoryGroups = displayedGroups.filter { $0.category == category }
|
|
|
|
|
if !categoryGroups.isEmpty {
|
|
|
|
|
ProcessCategoryHeader(
|
|
|
|
|
category: category,
|
|
|
|
@@ -106,13 +160,16 @@ struct ProcessesView: View {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
ForEach(grouped) { group in
|
|
|
|
|
ForEach(displayedGroups) { 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<Bool> {
|
|
|
|
@@ -178,27 +258,16 @@ struct ProcessesView: View {
|
|
|
|
|
private func processGroup(_ group: ProcessGroup) -> some View {
|
|
|
|
|
if group.processes.count > 1 {
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
HStack(spacing: 0) {
|
|
|
|
|
Button {
|
|
|
|
|
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(
|
|
|
|
|
group: group,
|
|
|
|
|
selected: selectedGroupID == group.id,
|
|
|
|
|
displayMode: resourceDisplayMode,
|
|
|
|
|
leadingPadding: 0
|
|
|
|
|
columnWidths: columnWidths,
|
|
|
|
|
isExpanded: expansionBinding(for: group),
|
|
|
|
|
onToggleExpansion: {
|
|
|
|
|
let binding = expansionBinding(for: group)
|
|
|
|
|
binding.wrappedValue.toggle()
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
|
.onTapGesture {
|
|
|
|
@@ -213,7 +282,6 @@ struct ProcessesView: View {
|
|
|
|
|
onShowProperties: { inspectedProcess = $0 }
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if expansionBinding(for: group).wrappedValue {
|
|
|
|
|
ForEach(group.processes.sorted { $0.pid < $1.pid }) { process in
|
|
|
|
@@ -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<Content: View>(
|
|
|
|
|
_ 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") }
|
|
|
|
|
}
|
|
|
|
|
.accessibilityAddTraits(.isButton)
|
|
|
|
|
.accessibilityLabel(title)
|
|
|
|
|
.accessibilityValue(sort == field ? (ascending ? "Sorted ascending" : "Sorted descending") : "Not sorted")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct ResizableProcessHeaderCell<Content: View>: 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")
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -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)
|
|
|
|
|
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)
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|