Improve process table columns and memory display
This commit is contained in:
@@ -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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- Collapsible Windows-style Apps, Background processes, and macOS processes categories
|
||||||
|
|||||||
@@ -15,6 +15,40 @@ private enum ResourceDisplayMode: String, CaseIterable {
|
|||||||
case percentages = "Percentages"
|
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 {
|
private enum ProcessCategory: String, CaseIterable, Identifiable {
|
||||||
case apps = "Apps"
|
case apps = "Apps"
|
||||||
case background = "Background processes"
|
case background = "Background processes"
|
||||||
@@ -35,6 +69,10 @@ struct ProcessesView: View {
|
|||||||
@AppStorage("processSortAscending") private var ascending = false
|
@AppStorage("processSortAscending") private var ascending = false
|
||||||
@AppStorage("processGroupByType") private var groupByType = true
|
@AppStorage("processGroupByType") private var groupByType = true
|
||||||
@AppStorage("processResourceDisplayMode") private var resourceDisplayMode: ResourceDisplayMode = .values
|
@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 terminationRequest: TerminationRequest?
|
||||||
@State private var groupTerminationRequest: ProcessGroup?
|
@State private var groupTerminationRequest: ProcessGroup?
|
||||||
@State private var inspectedProcess: ProcessRecord?
|
@State private var inspectedProcess: ProcessRecord?
|
||||||
@@ -65,6 +103,8 @@ struct ProcessesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var displayedGroups: [ProcessGroup] { groupsFrozenForResize ?? grouped }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") {
|
PageHeader(title: "Processes", subtitle: "\(monitor.processes.count) running processes") {
|
||||||
@@ -74,45 +114,62 @@ struct ProcessesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResourceSummaryBar(snapshot: monitor.snapshot)
|
ResourceSummaryBar(snapshot: monitor.snapshot)
|
||||||
ProcessTableHeader(
|
ScrollView(.horizontal) {
|
||||||
sort: $sort,
|
VStack(spacing: 0) {
|
||||||
ascending: $ascending,
|
ProcessTableHeader(
|
||||||
groupByType: $groupByType,
|
sort: $sort,
|
||||||
resourceDisplayMode: $resourceDisplayMode
|
ascending: $ascending,
|
||||||
)
|
groupByType: $groupByType,
|
||||||
ScrollView {
|
resourceDisplayMode: $resourceDisplayMode,
|
||||||
LazyVStack(spacing: 0) {
|
columnWidths: $columnWidths,
|
||||||
if groupByType {
|
onResizeStarted: {
|
||||||
ForEach(ProcessCategory.allCases) { category in
|
isResizingColumn = true
|
||||||
let categoryGroups = grouped.filter { $0.category == category }
|
if groupsFrozenForResize == nil { groupsFrozenForResize = grouped }
|
||||||
if !categoryGroups.isEmpty {
|
},
|
||||||
ProcessCategoryHeader(
|
onResizeEnded: {
|
||||||
category: category,
|
persistColumnWidths()
|
||||||
count: categoryGroups.count,
|
isResizingColumn = false
|
||||||
isExpanded: !collapsedCategories.contains(category) || !searchText.isEmpty
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||||
) {
|
if !isResizingColumn { groupsFrozenForResize = nil }
|
||||||
if collapsedCategories.contains(category) {
|
}
|
||||||
collapsedCategories.remove(category)
|
}
|
||||||
} else {
|
)
|
||||||
collapsedCategories.insert(category)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
if !collapsedCategories.contains(category) || !searchText.isEmpty {
|
ForEach(displayedGroups) { group in
|
||||||
ForEach(categoryGroups) { group in
|
processGroup(group)
|
||||||
processGroup(group)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
ForEach(grouped) { group in
|
|
||||||
processGroup(group)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, 10)
|
.frame(width: processTableWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
Divider()
|
Divider()
|
||||||
HStack {
|
HStack {
|
||||||
UpdateStatus()
|
UpdateStatus()
|
||||||
@@ -162,6 +219,29 @@ struct ProcessesView: View {
|
|||||||
.sheet(item: $inspectedProcess) { process in
|
.sheet(item: $inspectedProcess) { process in
|
||||||
ProcessPropertiesView(process: process)
|
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> {
|
private func expansionBinding(for group: ProcessGroup) -> Binding<Bool> {
|
||||||
@@ -178,41 +258,29 @@ struct ProcessesView: View {
|
|||||||
private func processGroup(_ group: ProcessGroup) -> some View {
|
private func processGroup(_ group: ProcessGroup) -> some View {
|
||||||
if group.processes.count > 1 {
|
if group.processes.count > 1 {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(spacing: 0) {
|
ProcessGroupRow(
|
||||||
Button {
|
group: group,
|
||||||
|
selected: selectedGroupID == group.id,
|
||||||
|
displayMode: resourceDisplayMode,
|
||||||
|
columnWidths: columnWidths,
|
||||||
|
isExpanded: expansionBinding(for: group),
|
||||||
|
onToggleExpansion: {
|
||||||
let binding = expansionBinding(for: group)
|
let binding = expansionBinding(for: group)
|
||||||
binding.wrappedValue.toggle()
|
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)
|
.contentShape(Rectangle())
|
||||||
.help(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
|
.onTapGesture {
|
||||||
.accessibilityLabel(expansionBinding(for: group).wrappedValue ? "Collapse \(group.name)" : "Expand \(group.name)")
|
selectedGroupID = group.id
|
||||||
|
selectedPID = nil
|
||||||
ProcessGroupRow(
|
}
|
||||||
|
.contextMenu {
|
||||||
|
ProcessGroupContextMenu(
|
||||||
group: group,
|
group: group,
|
||||||
selected: selectedGroupID == group.id,
|
onTerminate: { groupTerminationRequest = group },
|
||||||
displayMode: resourceDisplayMode,
|
onShowDetails: onShowDetails,
|
||||||
leadingPadding: 0
|
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 {
|
if expansionBinding(for: group).wrappedValue {
|
||||||
@@ -227,7 +295,13 @@ struct ProcessesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func processRow(_ process: ProcessRecord, isChild: Bool) -> some 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())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
selectedPID = process.pid
|
selectedPID = process.pid
|
||||||
@@ -362,16 +436,24 @@ private struct ProcessTableHeader: View {
|
|||||||
@Binding var ascending: Bool
|
@Binding var ascending: Bool
|
||||||
@Binding var groupByType: Bool
|
@Binding var groupByType: Bool
|
||||||
@Binding var resourceDisplayMode: ResourceDisplayMode
|
@Binding var resourceDisplayMode: ResourceDisplayMode
|
||||||
|
@Binding var columnWidths: [ProcessColumn: CGFloat]
|
||||||
|
let onResizeStarted: () -> Void
|
||||||
|
let onResizeEnded: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
header("Name", field: .name).frame(maxWidth: .infinity, alignment: .leading)
|
resizable(.name, alignment: .leading) { header("Name", field: .name, column: .name) }
|
||||||
header("PID", field: .pid).frame(width: 70, alignment: .trailing)
|
resizable(.pid, alignment: .leading) { header("PID", field: .pid, column: .pid) }
|
||||||
Text("Status").frame(width: 80, alignment: .leading)
|
resizable(.status, alignment: .leading) {
|
||||||
header("CPU", field: .cpu).frame(width: 86, alignment: .trailing)
|
Text("Status")
|
||||||
header("Memory", field: .memory).frame(width: 110, alignment: .trailing)
|
.lineLimit(1)
|
||||||
header("Disk", field: .disk).frame(width: 88, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
header("Network", field: .network).frame(width: 88, alignment: .trailing)
|
.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))
|
.font(.caption.weight(.medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -394,17 +476,100 @@ private struct ProcessTableHeader: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func header(_ title: String, field: ProcessSort) -> some View {
|
private func resizable<Content: View>(
|
||||||
Button {
|
_ 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
|
groupByType = false
|
||||||
if sort == field { ascending.toggle() } else { sort = field; ascending = 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<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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +579,7 @@ private struct ProcessRow: View {
|
|||||||
let selected: Bool
|
let selected: Bool
|
||||||
let isChild: Bool
|
let isChild: Bool
|
||||||
let displayMode: ResourceDisplayMode
|
let displayMode: ResourceDisplayMode
|
||||||
|
let columnWidths: [ProcessColumn: CGFloat]
|
||||||
|
|
||||||
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
|
private var activity: ProcessActivityRate { monitor.processActivity[process.pid] ?? ProcessActivityRate() }
|
||||||
|
|
||||||
@@ -424,25 +590,33 @@ private struct ProcessRow: View {
|
|||||||
Image(systemName: "arrow.turn.down.right")
|
Image(systemName: "arrow.turn.down.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.frame(width: 12)
|
.frame(width: 16)
|
||||||
|
} else {
|
||||||
|
Color.clear.frame(width: 16)
|
||||||
}
|
}
|
||||||
ProcessIcon(name: process.displayName, path: process.executablePath)
|
ProcessIcon(name: process.displayName, path: process.executablePath)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(process.displayName).lineLimit(1)
|
Text(process.displayName).lineLimit(1).truncationMode(.tail)
|
||||||
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
Text(process.user).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(width: width(.name), alignment: .leading)
|
||||||
Text("\(process.pid)").monospacedDigit().frame(width: 70, alignment: .trailing)
|
.clipped()
|
||||||
Text(process.state.hasPrefix("R") ? "Running" : "").frame(width: 80, alignment: .leading)
|
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)
|
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)
|
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)
|
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)
|
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
|
||||||
.frame(width: 88, alignment: .trailing)
|
.frame(width: width(.network), alignment: .trailing)
|
||||||
}
|
}
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -455,6 +629,14 @@ private struct ProcessRow: View {
|
|||||||
displayMode == .percentages ? Formatters.percent(process.memoryPercent) : Formatters.bytes.string(fromByteCount: Int64(process.residentBytes))
|
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 {
|
private var diskText: String {
|
||||||
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
|
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 group: ProcessGroup
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
let displayMode: ResourceDisplayMode
|
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) }
|
private var activity: ProcessActivityRate { group.activity(using: monitor.processActivity) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
HStack(spacing: 10) {
|
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)
|
ProcessIcon(name: group.name, path: group.appPath ?? group.primary.executablePath)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("\(group.name) (\(group.processes.count))").lineLimit(1)
|
Text("\(group.name) (\(group.processes.count))").lineLimit(1).truncationMode(.tail)
|
||||||
Text("Application group").font(.caption).foregroundStyle(.secondary)
|
Text("Application group").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(width: width(.name), alignment: .leading)
|
||||||
Text("—").frame(width: 70, alignment: .trailing).foregroundStyle(.secondary)
|
.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" : "")
|
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)
|
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)
|
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)
|
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)
|
HeatCell(text: networkText, intensity: activity.networkTotal / 5_000_000)
|
||||||
.frame(width: 88, alignment: .trailing)
|
.frame(width: width(.network), alignment: .trailing)
|
||||||
}
|
}
|
||||||
.font(.callout.weight(.medium))
|
.font(.callout.weight(.medium))
|
||||||
.padding(.leading, leadingPadding)
|
.padding(.horizontal, 16)
|
||||||
.padding(.trailing, 16)
|
|
||||||
.frame(height: 48)
|
.frame(height: 48)
|
||||||
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
.background(selected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||||
.overlay(alignment: .bottom) { Divider().opacity(0.45) }
|
.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))
|
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 {
|
private var diskText: String {
|
||||||
displayMode == .percentages ? share(activity.diskTotal, of: monitor.snapshot.diskThroughput) : Formatters.rate(activity.diskTotal)
|
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 {
|
var body: some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.72)
|
||||||
|
.truncationMode(.tail)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
.padding(.horizontal, 7)
|
.padding(.horizontal, processNumericTextInset)
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
.clipped()
|
||||||
.background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32)))
|
.background(Color.accentColor.opacity(0.05 + min(0.32, max(0, intensity) * 0.32)))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -3,8 +3,12 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROJECT_DIR="${0:A:h:h}"
|
PROJECT_DIR="${0:A:h:h}"
|
||||||
APP_DIR="$PROJECT_DIR/dist/Task Manager.app"
|
APP_DIR="$PROJECT_DIR/dist/Task Manager.app"
|
||||||
CONTENTS_DIR="$APP_DIR/Contents"
|
|
||||||
ICON_SOURCE="$PROJECT_DIR/mactaskmanager.icon"
|
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"
|
cd "$PROJECT_DIR"
|
||||||
swift build -c release
|
swift build -c release
|
||||||
@@ -23,5 +27,12 @@ if [[ -d "$ICON_SOURCE" ]]; then
|
|||||||
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null
|
--output-partial-info-plist "$CONTENTS_DIR/Resources/IconInfo.plist" >/dev/null
|
||||||
fi
|
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"
|
print "Built $APP_DIR"
|
||||||
|
|||||||
Reference in New Issue
Block a user