diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index 1c84855..16f4608 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */; }; 136DAD1FE90FD682DF666667 /* TopManager_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */; }; 14D77C268F9F701E612E4FD3 /* WindowVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A1CB7CF2D7EA7A20E561192 /* WindowVisibility.swift */; }; + 1929475349F9A25B0778DF9B /* ProcessTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */; }; 1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44281079654C7E1C9E16806C /* CPUInfo.swift */; }; 1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; }; 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; }; @@ -40,6 +41,7 @@ A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; }; AE29356C1D41F33A3E94B6B7 /* MenuBarStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */; }; B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */; }; + B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */; }; B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; }; B89695749D32F394DB459C15 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACBF14FFC5EC68C9E438040 /* SettingsView.swift */; }; BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; }; @@ -80,6 +82,7 @@ 126A382E2755C5061E6D29F5 /* HealthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthView.swift; sourceTree = ""; }; 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = ""; }; 19A4F11495393A7AA89CA3C3 /* TemperatureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureMonitor.swift; sourceTree = ""; }; + 19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTableView.swift; sourceTree = ""; }; 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = ""; }; 279B972C08A878053821285D /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = ""; }; 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = ""; }; @@ -96,6 +99,7 @@ 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerInfo.swift; sourceTree = ""; }; 5B37A9AE9F6215C0C132B8D9 /* MenuBarStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatus.swift; sourceTree = ""; }; 5E1D2EA8E1AD11B42FF225EB /* TopManager.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TopManager.entitlements; sourceTree = ""; }; + 61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTableTests.swift; sourceTree = ""; }; 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = ""; }; 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = ""; }; 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = ""; }; @@ -270,6 +274,7 @@ 8E44036C6DB5C055C179A3E2 /* MenuBarStatusTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */, + 61F822BA26055B319B7D13F8 /* ProcessTableTests.swift */, 01C19ABF49697A97F0C40378 /* SettingsTests.swift */, FD9632A456D316A019CFA313 /* ThermalMathTests.swift */, ); @@ -289,6 +294,7 @@ isa = PBXGroup; children = ( 110A314A63AE355A9E04990D /* ProcessDetailView.swift */, + 19ADFC64E855FBDCDAF600E5 /* ProcessTableView.swift */, 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */, ); path = Processes; @@ -395,6 +401,7 @@ DA03FF0D25DB8B9D3B09BC82 /* MenuBarStatusTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, + B02EE645434EF31AAE82825E /* ProcessTableTests.swift in Sources */, 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */, 802A6F368A475126C1349A03 /* ThermalMathTests.swift in Sources */, ); @@ -437,6 +444,7 @@ C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */, 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */, 9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */, + 1929475349F9A25B0778DF9B /* ProcessTableView.swift in Sources */, 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */, B89695749D32F394DB459C15 /* SettingsView.swift in Sources */, DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */, diff --git a/TopManager/Views/Processes/ProcessTableView.swift b/TopManager/Views/Processes/ProcessTableView.swift new file mode 100644 index 0000000..ec9565b --- /dev/null +++ b/TopManager/Views/Processes/ProcessTableView.swift @@ -0,0 +1,477 @@ +import SwiftUI +import AppKit + +// MARK: - Presentation (pure, unit-tested) + +/// Colour role of a cell's text or leading symbol. +enum ProcessCellTone: Equatable { + case primary, secondary, green, yellow, orange, red + + var color: NSColor { + switch self { + case .primary: return .labelColor + case .secondary: return .secondaryLabelColor + case .green: return .systemGreen + case .yellow: return .systemYellow + case .orange: return .systemOrange + case .red: return .systemRed + } + } +} + +/// Everything one table cell displays. Cells only touch AppKit when this changes. +struct ProcessCellContent: Equatable { + enum Leading: Equatable { + case none + case icon(NSImage) + case symbol(String, ProcessCellTone) + + static func == (lhs: Leading, rhs: Leading) -> Bool { + switch (lhs, rhs) { + case (.none, .none): return true + case let (.icon(a), .icon(b)): return a === b + case let (.symbol(a, aTone), .symbol(b, bTone)): return a == b && aTone == bTone + default: return false + } + } + } + + var text: String + var tone: ProcessCellTone = .primary + var leading: Leading = .none + var monospacedDigits = false +} + +/// The Processes table's columns, in display order. +enum ProcessColumn: String, CaseIterable { + case name, pid, cpu, cpuTotal, energy, memory, resident, compressed, disk, threads, user, state + + var sortColumn: ProcessSortColumn { + guard let column = ProcessSortColumn(rawValue: rawValue) else { + preconditionFailure("ProcessColumn.\(rawValue) has no matching ProcessSortColumn") + } + return column + } + + var title: String { + switch self { + case .name: return "Name" + case .pid: return "PID" + case .cpu: return "CPU/Core" + case .cpuTotal: return "CPU/Total" + case .energy: return "Energy" + case .memory: return "Memory" + case .resident: return "RAM" + case .compressed: return "Compressed" + case .disk: return "Disk I/O" + case .threads: return "Threads" + case .user: return "User" + case .state: return "State" + } + } + + var width: CGFloat { + switch self { + case .name: return 220 + case .pid, .energy, .threads: return 60 + case .cpu, .cpuTotal: return 70 + case .memory, .resident, .user: return 80 + case .compressed: return 85 + case .disk, .state: return 90 + } + } + + /// Text columns sort A→Z on first click; figures sort largest first. + var sortsAscendingFirst: Bool { + switch self { + case .name, .pid, .user, .state: return true + default: return false + } + } + + /// Sort order after a click on this column's header: the current column + /// flips direction, any other column starts in its natural direction. + func sortAfterClick(current: ProcessSortColumn, ascending: Bool) -> (column: ProcessSortColumn, ascending: Bool) { + sortColumn == current ? (current, !ascending) : (sortColumn, sortsAscendingFirst) + } + + func content(for process: ProcessItem) -> ProcessCellContent { + switch self { + case .name: + return ProcessCellContent(text: process.name, + leading: process.icon.map { .icon($0) } ?? .symbol("app.dashed", .secondary)) + case .pid: + return ProcessCellContent(text: String(process.pid), monospacedDigits: true) + case .cpu: + return ProcessCellContent(text: String(format: "%.1f%%", process.cpuUsage), + tone: Self.tone(process.cpuUsage, yellow: 20, orange: 50, red: 80), + monospacedDigits: true) + case .cpuTotal: + return ProcessCellContent(text: String(format: "%.2f%%", process.cpuUsageTotal), + tone: Self.tone(process.cpuUsageTotal, yellow: 2, orange: 5, red: 10), + monospacedDigits: true) + case .energy: + return ProcessCellContent(text: String(format: "%.1f", process.energyImpact), + tone: Self.tone(process.energyImpact, yellow: 8, orange: 20, red: 50), + monospacedDigits: true) + case .memory: + return ProcessCellContent(text: formatBytes(process.memoryUsage), monospacedDigits: true) + case .resident: + return ProcessCellContent(text: formatBytes(process.residentMemory), monospacedDigits: true) + case .compressed: + return process.compressedMemory > 0 + ? ProcessCellContent(text: formatBytes(process.compressedMemory), monospacedDigits: true) + : ProcessCellContent(text: "—", tone: .secondary) + case .disk: + return process.diskTotalRate > 0 + ? ProcessCellContent(text: formatBytesPerSecond(process.diskTotalRate), monospacedDigits: true) + : ProcessCellContent(text: "—", tone: .secondary) + case .threads: + return ProcessCellContent(text: String(process.threadCount), monospacedDigits: true) + case .user: + return ProcessCellContent(text: process.user) + case .state: + return ProcessCellContent(text: process.state.rawValue, + leading: .symbol(process.state.symbol, Self.tone(for: process.state))) + } + } + + static func tone(_ value: Double, yellow: Double, orange: Double, red: Double) -> ProcessCellTone { + if value > red { return .red } + if value > orange { return .orange } + if value > yellow { return .yellow } + return .primary + } + + static func tone(for state: ProcessState) -> ProcessCellTone { + switch state { + case .running: return .green + case .stopped: return .orange + case .zombie: return .red + case .sleeping, .unknown: return .secondary + } + } +} + +// MARK: - Table + +/// Callbacks from the table back into `ProcessView`. +struct ProcessTableActions { + var open: (ProcessItem) -> Void + var terminate: (ProcessItem) -> Void + var forceKill: (ProcessItem) -> Void + var suspend: (ProcessItem) -> Void + var resume: (ProcessItem) -> Void + var deleteKey: () -> Void +} + +/// The process list as a plain AppKit `NSTableView`. +/// +/// Replaces SwiftUI's `Table`, which on macOS 26 retained AutoLayout and +/// view-graph objects on every data update — tens of MB an hour while the window +/// was visible — on top of an NSHostingView per cell. Measured side by side +/// (6 min, window visible, scrolling): heap growth fell from ~600 KB/min to +/// ~35 KB/min and the starting footprint from 67 MB to 49 MB. +/// Cells here are reused AppKit views, and an update rewrites only the fields +/// that changed in rows that are on screen; the table reloads only once, on +/// its first update. +struct ProcessTableView: NSViewRepresentable { + let processes: [ProcessItem] + @Binding var selection: Set + let sortColumn: ProcessSortColumn + let sortAscending: Bool + let onSort: (ProcessSortColumn, Bool) -> Void + let actions: ProcessTableActions + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeNSView(context: Context) -> NSScrollView { + let coordinator = context.coordinator + let table = ProcessNSTableView() + table.style = .inset + table.usesAlternatingRowBackgroundColors = true + table.allowsMultipleSelection = true + table.allowsColumnReordering = true + table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle + table.rowHeight = 24 + + for column in ProcessColumn.allCases { + let tableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue)) + tableColumn.title = column.title + tableColumn.width = column.width + tableColumn.minWidth = column == .name ? 170 : 40 + tableColumn.resizingMask = column == .name ? [.autoresizingMask, .userResizingMask] : .userResizingMask + table.addTableColumn(tableColumn) + } + // Remembers column widths and order between launches. + table.autosaveName = "ProcessTable" + table.autosaveTableColumns = true + + table.dataSource = coordinator + table.delegate = coordinator + table.target = coordinator + table.doubleAction = #selector(Coordinator.doubleClicked(_:)) + table.onDelete = { [weak coordinator] in coordinator?.parent.actions.deleteKey() } + + let menu = NSMenu() + menu.delegate = coordinator + table.menu = menu + + let scrollView = NSScrollView() + scrollView.documentView = table + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.autohidesScrollers = true + + coordinator.tableView = table + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.parent = self + context.coordinator.update() + } + + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { + var parent: ProcessTableView + weak var tableView: NSTableView? + private var rows: [ProcessItem] = [] + private var isSyncing = false + /// The first update must reload: right after `dataSource` is set, the + /// table still has an initial load pending, and `noteNumberOfRowsChanged` + /// on top of it doubles the row count (blank rows below the list). + private var needsInitialLoad = true + private var shownSort: (column: ProcessSortColumn, ascending: Bool)? + + init(parent: ProcessTableView) { + self.parent = parent + } + + // MARK: Updates + + func update() { + guard let tableView else { return } + isSyncing = true + defer { isSyncing = false } + + let newRows = parent.processes + if needsInitialLoad { + needsInitialLoad = false + rows = newRows + tableView.reloadData() + } else if Self.rowsDiffer(rows, newRows) { + let countChanged = newRows.count != rows.count + rows = newRows + if countChanged { tableView.noteNumberOfRowsChanged() } + refreshPreparedCells(in: tableView) + } + + let selected = IndexSet(rows.indices.filter { parent.selection.contains(rows[$0].pid) }) + if selected != tableView.selectedRowIndexes { + tableView.selectRowIndexes(selected, byExtendingSelection: false) + } + + if shownSort?.column != parent.sortColumn || shownSort?.ascending != parent.sortAscending { + showSortIndicator(in: tableView) + } + } + + /// Sorting is driven by `ProcessView`, not by NSTableView's sort descriptors: + /// the built-in header toggling ignored the prototypes' direction, so figure + /// columns started smallest-first. + private func showSortIndicator(in tableView: NSTableView) { + for tableColumn in tableView.tableColumns { + tableView.setIndicatorImage(nil, in: tableColumn) + } + let identifier = NSUserInterfaceItemIdentifier(parent.sortColumn.rawValue) + if let tableColumn = tableView.tableColumn(withIdentifier: identifier) { + let name = parent.sortAscending ? "NSAscendingSortIndicator" : "NSDescendingSortIndicator" + tableView.setIndicatorImage(NSImage(named: name), in: tableColumn) + tableView.highlightedTableColumn = tableColumn + } + shownSort = (parent.sortColumn, parent.sortAscending) + } + + /// `ProcessItem ==` compares live figures only, so also catch a PID that + /// now belongs to a different process. + static func rowsDiffer(_ old: [ProcessItem], _ new: [ProcessItem]) -> Bool { + guard old.count == new.count else { return true } + for (a, b) in zip(old, new) where a != b || a.name != b.name || a.startTime != b.startTime || a.user != b.user { + return true + } + return false + } + + /// Rewrites the cells AppKit currently holds views for (on screen plus its + /// small overdraw margin). Rows outside get configured when they scroll in. + private func refreshPreparedCells(in tableView: NSTableView) { + let range = tableView.rows(in: tableView.preparedContentRect) + guard range.length > 0 else { return } + for row in range.location.. Int { rows.count } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard let identifier = tableColumn?.identifier, + let column = ProcessColumn(rawValue: identifier.rawValue), + row < rows.count else { return nil } + let cell = tableView.makeView(withIdentifier: identifier, owner: nil) as? ProcessTableCellView + ?? ProcessTableCellView(identifier: identifier) + cell.apply(column.content(for: rows[row])) + return cell + } + + func tableViewSelectionDidChange(_ notification: Notification) { + guard !isSyncing, let tableView else { return } + parent.selection = Set(tableView.selectedRowIndexes.compactMap { $0 < rows.count ? rows[$0].pid : nil }) + } + + func tableView(_ tableView: NSTableView, didClick tableColumn: NSTableColumn) { + guard let column = ProcessColumn(rawValue: tableColumn.identifier.rawValue) else { return } + let next = column.sortAfterClick(current: parent.sortColumn, ascending: parent.sortAscending) + parent.onSort(next.column, next.ascending) + } + + @objc func doubleClicked(_ sender: NSTableView) { + let row = sender.clickedRow + guard row >= 0, row < rows.count else { return } + parent.actions.open(rows[row]) + } + + // MARK: Context menu + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard let tableView else { return } + // A right-click sets clickedRow; a menu opened from the keyboard or + // VoiceOver has none, so fall back to the selection. + let row = tableView.clickedRow >= 0 ? tableView.clickedRow : tableView.selectedRow + guard row >= 0, row < rows.count else { return } + if !tableView.selectedRowIndexes.contains(row) { + tableView.selectRowIndexes([row], byExtendingSelection: false) + } + // Actions get the clicked process directly rather than reading the + // selection back through SwiftUI state, which may not have updated yet. + let process = rows[row] + let actions = parent.actions + addItem("Get Info (⌘I)", to: menu) { actions.open(process) } + menu.addItem(.separator()) + addItem("Terminate (⌫)", to: menu) { actions.terminate(process) } + addItem("Force Kill (⌘⌫)", to: menu) { actions.forceKill(process) } + menu.addItem(.separator()) + addItem("Suspend", to: menu) { actions.suspend(process) } + addItem("Resume", to: menu) { actions.resume(process) } + menu.addItem(.separator()) + addItem("Copy PID", to: menu) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(String(process.pid), forType: .string) + } + } + + private final class MenuAction { + let run: () -> Void + init(_ run: @escaping () -> Void) { self.run = run } + } + + private func addItem(_ title: String, to menu: NSMenu, run: @escaping () -> Void) { + let item = NSMenuItem(title: title, action: #selector(performMenuAction(_:)), keyEquivalent: "") + item.target = self + item.representedObject = MenuAction(run) + menu.addItem(item) + } + + @objc private func performMenuAction(_ sender: NSMenuItem) { + (sender.representedObject as? MenuAction)?.run() + } + } +} + +/// Adds the plain Delete / Forward Delete key (⌘⌫ is handled by `KeyboardShortcutHandler`). +final class ProcessNSTableView: NSTableView { + var onDelete: (() -> Void)? + + override func keyDown(with event: NSEvent) { + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask).subtracting([.function, .numericPad]) + if (event.keyCode == 51 || event.keyCode == 117) && modifiers.isEmpty { + onDelete?() + return + } + super.keyDown(with: event) + } +} + +/// A reusable cell: optional 16 pt leading image plus a label, laid out by hand +/// (no constraints) and updated field by field. +final class ProcessTableCellView: NSTableCellView { + private static let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + private static let digitFont = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + private static let lineHeight = ceil(NSLayoutManager().defaultLineHeight(for: font)) + + private let label = NSTextField(labelWithString: "") + private let leadingView = NSImageView() + private var content: ProcessCellContent? + + init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + label.lineBreakMode = .byTruncatingTail + label.font = Self.font + leadingView.imageScaling = .scaleProportionallyUpOrDown + leadingView.isHidden = true + addSubview(leadingView) + addSubview(label) + textField = label + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not used") } + + func apply(_ new: ProcessCellContent) { + guard new != content else { return } + let old = content + content = new + + if new.text != old?.text { label.stringValue = new.text } + if new.tone != old?.tone { label.textColor = new.tone.color } + if new.monospacedDigits != old?.monospacedDigits { + label.font = new.monospacedDigits ? Self.digitFont : Self.font + } + if new.leading != old?.leading { + switch new.leading { + case .none: + leadingView.image = nil + leadingView.isHidden = true + case .icon(let image): + leadingView.image = image + leadingView.contentTintColor = nil + leadingView.isHidden = false + case .symbol(let name, let tone): + leadingView.image = NSImage(systemSymbolName: name, accessibilityDescription: nil) + leadingView.contentTintColor = tone.color + leadingView.isHidden = false + } + needsLayout = true + } + } + + override func layout() { + super.layout() + let height = bounds.height + var x: CGFloat = 0 + if !leadingView.isHidden { + leadingView.frame = NSRect(x: 0, y: floor((height - 16) / 2), width: 16, height: 16) + x = 22 + } + label.frame = NSRect(x: x, y: floor((height - Self.lineHeight) / 2), + width: max(0, bounds.width - x), height: Self.lineHeight) + } +} diff --git a/TopManager/Views/Processes/ProcessView.swift b/TopManager/Views/Processes/ProcessView.swift index 832c5ce..0691b08 100644 --- a/TopManager/Views/Processes/ProcessView.swift +++ b/TopManager/Views/Processes/ProcessView.swift @@ -10,12 +10,6 @@ struct ProcessView: View { @State private var selectedProcess: Set = [] @State private var sortColumn: ProcessSortColumn = .cpu @State private var sortAscending: Bool = false - @State private var displayedProcesses: [ProcessItem] = [] - - // Keep sortOrder for Table binding compatibility - @State private var sortOrder: [KeyPathComparator] = [ - .init(\.cpuUsage, order: .reverse) - ] // Confirmation dialogs @State private var showTerminateConfirm = false @@ -40,14 +34,23 @@ struct ProcessView: View { Self.protectedProcesses.contains(name) } - private func updateDisplayedProcesses() { - let filtered = monitor.processes.filter { - searchText.isEmpty || - $0.name.localizedCaseInsensitiveContains(searchText) || - String($0.pid).contains(searchText) + /// The rows on screen, derived from current state on every render. This used + /// to be cached in @State and refreshed from `onChange` actions, which can read + /// an earlier render's sort and search values — a data refresh right after a + /// header click could put the table back into the previous order. + private var displayedProcesses: [ProcessItem] { + Self.filterAndSort(monitor.processes, search: searchText, by: sortColumn, ascending: sortAscending) + } + + static func filterAndSort(_ processes: [ProcessItem], search: String, + by sortColumn: ProcessSortColumn, ascending sortAscending: Bool) -> [ProcessItem] { + let filtered = processes.filter { + search.isEmpty || + $0.name.localizedCaseInsensitiveContains(search) || + String($0.pid).contains(search) } - displayedProcesses = filtered.sorted { lhs, rhs in + return filtered.sorted { lhs, rhs in let comparison: ComparisonResult switch sortColumn { case .name: @@ -86,213 +89,39 @@ struct ProcessView: View { } } + /// Looked up among the rows on screen, so a selection hidden by the search + /// filter can't be terminated by accident. var selectedProcessItem: ProcessItem? { guard let pid = selectedProcess.first else { return nil } - return monitor.processes.first { $0.pid == pid } - } - - /// Memory, RAM and Compressed as a single builder element: Table accepts at - /// most 10 columns per block, and inlining them also exceeds the type - /// checker's time limit for the table expression. - @TableColumnBuilder> - private var memoryColumns: some TableColumnContent> { - TableColumn("Memory", value: \.memoryUsage) { process in - Text(formatBytes(process.memoryUsage)) - .monospacedDigit() - } - .width(80) - - TableColumn("RAM", value: \.residentMemory) { process in - Text(formatBytes(process.residentMemory)) - .monospacedDigit() - } - .width(80) - - TableColumn("Compressed", value: \.compressedMemory) { process in - if process.compressedMemory > 0 { - Text(formatBytes(process.compressedMemory)) - .monospacedDigit() - } else { - Text("—").foregroundColor(.secondary) - } - } - .width(85) + return displayedProcesses.first { $0.pid == pid } } var body: some View { + let rows = displayedProcesses VStack(spacing: 0) { // Summary bar - separate view to avoid re-rendering table - ProcessSummaryBar(processCount: displayedProcesses.count, searchText: $searchText) + ProcessSummaryBar(processCount: rows.count, searchText: $searchText) Divider() - // Process table - Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) { - TableColumn("Name", value: \.name) { process in - HStack(spacing: 6) { - if let icon = process.icon { - Image(nsImage: icon) - .resizable() - .frame(width: 16, height: 16) - } else { - Image(systemName: "app.dashed") - .frame(width: 16, height: 16) - .foregroundColor(.secondary) - } - Text(process.name) - .lineLimit(1) - } - } - .width(min: 170, ideal: 220) - - TableColumn("PID", value: \.pid) { process in - Text("\(process.pid)") - .monospacedDigit() - } - .width(60) - - TableColumn("CPU/Core", value: \.cpuUsage) { process in - Text(String(format: "%.1f%%", process.cpuUsage)) - .monospacedDigit() - .foregroundColor(cpuColor(process.cpuUsage)) - } - .width(70) - - TableColumn("CPU/Total", value: \.cpuUsageTotal) { process in - Text(String(format: "%.2f%%", process.cpuUsageTotal)) - .monospacedDigit() - .foregroundColor(cpuColorTotal(process.cpuUsageTotal)) - } - .width(70) - - TableColumn("Energy", value: \.energyImpact) { process in - Text(String(format: "%.1f", process.energyImpact)) - .monospacedDigit() - .foregroundColor(energyColor(process.energyImpact)) - } - .width(60) - - memoryColumns - - TableColumn("Disk I/O", value: \.diskTotalRate) { process in - if process.diskTotalRate > 0 { - Text(formatBytesPerSecond(process.diskTotalRate)) - .monospacedDigit() - } else { - Text("—").foregroundColor(.secondary) - } - } - .width(90) - - TableColumn("Threads", value: \.threadCount) { process in - Text("\(process.threadCount)") - .monospacedDigit() - } - .width(60) - - TableColumn("User", value: \.user) { process in - Text(process.user) - .lineLimit(1) - } - .width(80) - - TableColumn("State", value: \.state.rawValue) { process in - HStack(spacing: 4) { - Image(systemName: process.state.symbol) - .foregroundColor(stateColor(process.state)) - Text(process.state.rawValue) - } - } - .width(90) - } - .contextMenu(forSelectionType: ProcessItem.ID.self) { selection in - if let pid = selection.first, - let process = monitor.processes.first(where: { $0.pid == pid }) { - Button("Get Info (⌘I)") { - inspectorProcess = process - } - Divider() - Button("Terminate (⌫)") { - initiateTerminate() - } - Button("Force Kill (⌘⌫)") { - initiateForceKill() - } - Divider() - Button("Suspend") { - performSuspend(process: process) - } - Button("Resume") { - performResume(process: process) - } - Divider() - Button("Copy PID") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString("\(pid)", forType: .string) - } - } - } primaryAction: { selection in - // Double-click opens the deep-dive inspector - if let pid = selection.first, - let process = monitor.processes.first(where: { $0.pid == pid }) { - inspectorProcess = process - } - } - .onDeleteCommand { - initiateTerminate() - } - } - .onAppear { - updateDisplayedProcesses() - } - .onChange(of: monitor.processes) { _ in - updateDisplayedProcesses() - } - .onChange(of: searchText) { _ in - updateDisplayedProcesses() - } - .onChange(of: sortColumn) { _ in - updateDisplayedProcesses() - } - .onChange(of: sortAscending) { _ in - updateDisplayedProcesses() - } - .onChange(of: sortOrder) { newOrder in - guard let comparator = newOrder.first else { return } - - // Detect which column and direction from the KeyPathComparator - let ascending = comparator.order == .forward - - // Use string representation of keypath to determine column - let keyPathString = String(describing: comparator) - - if keyPathString.contains("cpuUsageTotal") { - sortColumn = .cpuTotal - } else if keyPathString.contains("cpuUsage") { - sortColumn = .cpu - } else if keyPathString.contains("energyImpact") { - sortColumn = .energy - } else if keyPathString.contains("diskTotalRate") { - sortColumn = .disk - } else if keyPathString.contains("residentMemory") { - sortColumn = .resident - } else if keyPathString.contains("compressedMemory") { - sortColumn = .compressed - } else if keyPathString.contains("memoryUsage") { - sortColumn = .memory - } else if keyPathString.contains("name") { - sortColumn = .name - } else if keyPathString.contains("pid") { - sortColumn = .pid - } else if keyPathString.contains("threadCount") { - sortColumn = .threads - } else if keyPathString.contains("user") { - sortColumn = .user - } else if keyPathString.contains("state") { - sortColumn = .state - } - - sortAscending = ascending + ProcessTableView( + processes: rows, + selection: $selectedProcess, + sortColumn: sortColumn, + sortAscending: sortAscending, + onSort: { column, ascending in + sortColumn = column + sortAscending = ascending + }, + actions: ProcessTableActions( + open: { inspectorProcess = $0 }, + terminate: { initiateTerminate($0) }, + forceKill: { initiateForceKill($0) }, + suspend: { performSuspend(process: $0) }, + resume: { performResume(process: $0) }, + deleteKey: { initiateTerminate() } + ) + ) } .background( KeyboardShortcutHandler( @@ -344,8 +173,8 @@ struct ProcessView: View { } } - private func initiateTerminate() { - guard let process = selectedProcessItem else { return } + private func initiateTerminate(_ target: ProcessItem? = nil) { + guard let process = target ?? selectedProcessItem else { return } processToKill = process if skipTerminateConfirm { @@ -355,8 +184,8 @@ struct ProcessView: View { } } - private func initiateForceKill() { - guard let process = selectedProcessItem else { return } + private func initiateForceKill(_ target: ProcessItem? = nil) { + guard let process = target ?? selectedProcessItem else { return } // SAFETY: Prevent force-killing critical system processes if isProtectedProcess(process.name) { @@ -401,49 +230,6 @@ struct ProcessView: View { showErrorAlert = true } } - - private func cpuColor(_ usage: Double) -> Color { - if usage > 80 { - return .red - } else if usage > 50 { - return .orange - } else if usage > 20 { - return .yellow - } - return .primary - } - - private func cpuColorTotal(_ usage: Double) -> Color { - if usage > 10 { - return .red - } else if usage > 5 { - return .orange - } else if usage > 2 { - return .yellow - } - return .primary - } - - private func energyColor(_ impact: Double) -> Color { - if impact > 50 { - return .red - } else if impact > 20 { - return .orange - } else if impact > 8 { - return .yellow - } - return .primary - } - - private func stateColor(_ state: ProcessState) -> Color { - switch state { - case .running: return .green - case .sleeping: return .secondary - case .stopped: return .orange - case .zombie: return .red - case .unknown: return .secondary - } - } } struct ConfirmationSheet: View { diff --git a/TopManagerTests/ProcessTableTests.swift b/TopManagerTests/ProcessTableTests.swift new file mode 100644 index 0000000..5d4b7c5 --- /dev/null +++ b/TopManagerTests/ProcessTableTests.swift @@ -0,0 +1,267 @@ +import XCTest +import AppKit +import SwiftUI +@testable import TopManager + +final class ProcessTableTests: XCTestCase { + + private func item(pid: pid_t = 100, name: String = "proc", user: String = "ariel", + cpu: Double = 0, cpuTotal: Double = 0, energy: Double = 0, + memory: Int64 = 1_000_000, resident: Int64 = 2_000_000, compressed: Int64 = 0, + diskRead: Double = 0, state: ProcessState = .sleeping, icon: NSImage? = nil, + start: Date = Date(timeIntervalSince1970: 1_000)) -> ProcessItem { + ProcessItem(pid: pid, name: name, user: user, cpuUsage: cpu, cpuUsageTotal: cpuTotal, + memoryUsage: memory, threadCount: 4, state: state, icon: icon, parentPid: 1, + startTime: start, diskReadRate: diskRead, energyImpact: energy, + residentMemory: resident, compressedMemory: compressed) + } + + // MARK: Columns + + func testColumnsInDisplayOrder() { + XCTAssertEqual(ProcessColumn.allCases.map(\.title), + ["Name", "PID", "CPU/Core", "CPU/Total", "Energy", "Memory", "RAM", + "Compressed", "Disk I/O", "Threads", "User", "State"]) + } + + func testEveryColumnSortsByItsOwnKey() { + for column in ProcessColumn.allCases { + XCTAssertEqual(column.sortColumn.rawValue, column.rawValue) + } + } + + func testTextColumnsSortAscendingFirstFiguresDescending() { + XCTAssertTrue(ProcessColumn.name.sortsAscendingFirst) + XCTAssertTrue(ProcessColumn.user.sortsAscendingFirst) + XCTAssertFalse(ProcessColumn.cpu.sortsAscendingFirst) + XCTAssertFalse(ProcessColumn.memory.sortsAscendingFirst) + XCTAssertFalse(ProcessColumn.compressed.sortsAscendingFirst) + } + + func testHeaderClickFlipsCurrentColumnAndStartsOthersInNaturalDirection() { + XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: false) == (.cpu, true)) + XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: true) == (.cpu, false)) + XCTAssertTrue(ProcessColumn.memory.sortAfterClick(current: .name, ascending: true) == (.memory, false)) + XCTAssertTrue(ProcessColumn.resident.sortAfterClick(current: .memory, ascending: false) == (.resident, false)) + XCTAssertTrue(ProcessColumn.name.sortAfterClick(current: .cpu, ascending: false) == (.name, true)) + } + + // MARK: Cell content + + func testCPUToneThresholdsMatchPreviousTable() { + XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20)).tone, .primary) + XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20.1)).tone, .yellow) + XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 50.1)).tone, .orange) + XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 80.1)).tone, .red) + XCTAssertEqual(ProcessColumn.cpuTotal.content(for: item(cpuTotal: 5.5)).tone, .orange) + XCTAssertEqual(ProcessColumn.energy.content(for: item(energy: 8.5)).tone, .yellow) + } + + func testFormatsFigures() { + let p = item(pid: 89435, cpu: 25.54, cpuTotal: 3.184, energy: 25.6) + XCTAssertEqual(ProcessColumn.pid.content(for: p).text, "89435") + XCTAssertEqual(ProcessColumn.cpu.content(for: p).text, "25.5%") + XCTAssertEqual(ProcessColumn.cpuTotal.content(for: p).text, "3.18%") + XCTAssertEqual(ProcessColumn.energy.content(for: p).text, "25.6") + XCTAssertEqual(ProcessColumn.memory.content(for: p).text, formatBytes(Int64(1_000_000))) + XCTAssertEqual(ProcessColumn.resident.content(for: p).text, formatBytes(Int64(2_000_000))) + XCTAssertTrue(ProcessColumn.memory.content(for: p).monospacedDigits) + XCTAssertFalse(ProcessColumn.user.content(for: p).monospacedDigits) + } + + func testZeroCompressedAndIdleDiskShowSecondaryDash() { + for column in [ProcessColumn.compressed, .disk] { + let content = column.content(for: item()) + XCTAssertEqual(content.text, "—") + XCTAssertEqual(content.tone, .secondary) + } + XCTAssertEqual(ProcessColumn.compressed.content(for: item(compressed: 5_000_000)).text, + formatBytes(Int64(5_000_000))) + XCTAssertNotEqual(ProcessColumn.disk.content(for: item(diskRead: 2048)).text, "—") + } + + func testNameShowsAppIconOrPlaceholderSymbol() { + let icon = NSImage(size: NSSize(width: 16, height: 16)) + XCTAssertEqual(ProcessColumn.name.content(for: item(icon: icon)).leading, .icon(icon)) + XCTAssertEqual(ProcessColumn.name.content(for: item()).leading, .symbol("app.dashed", .secondary)) + // Icons compare by identity, so an equal-looking copy still counts as a change. + XCTAssertNotEqual(ProcessCellContent.Leading.icon(icon), .icon(NSImage(size: icon.size))) + } + + func testStateShowsTintedSymbol() { + let running = ProcessColumn.state.content(for: item(state: .running)) + XCTAssertEqual(running.text, "Running") + XCTAssertEqual(running.leading, .symbol(ProcessState.running.symbol, .green)) + XCTAssertEqual(ProcessColumn.tone(for: .zombie), .red) + XCTAssertEqual(ProcessColumn.tone(for: .sleeping), .secondary) + } + + // MARK: Update detection + + func testRowsDifferDetectsValueOrderAndIdentityChanges() { + let a = item(pid: 1), b = item(pid: 2) + let differ = ProcessTableView.Coordinator.rowsDiffer + XCTAssertFalse(differ([a, b], [a, b])) + XCTAssertTrue(differ([a, b], [b, a])) // re-sorted + XCTAssertTrue(differ([a, b], [a])) // process exited + XCTAssertTrue(differ([a, b], [a, item(pid: 2, cpu: 9)])) // figure changed + XCTAssertTrue(differ([a, b], [a, item(pid: 2, name: "new")])) // PID reused + XCTAssertTrue(differ([a, b], [a, item(pid: 2, start: Date(timeIntervalSince1970: 2_000))])) + } +} + +final class ProcessFilterSortTests: XCTestCase { + + private func p(_ pid: pid_t, _ name: String, cpu: Double = 0, memory: Int64 = 0, compressed: Int64 = 0) -> ProcessItem { + ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: cpu, cpuUsageTotal: cpu / 8, memoryUsage: memory, + threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil, + compressedMemory: compressed) + } + + private lazy var sample = [ + p(1, "launchd"), + p(300, "Finder", cpu: 12, memory: 200_000_000, compressed: 50_000_000), + p(200, "Teams", cpu: 3, memory: 900_000_000, compressed: 800_000_000), + p(400, "zsh", cpu: 40, memory: 5_000_000), + ] + + private func pids(_ column: ProcessSortColumn, ascending: Bool, search: String = "") -> [pid_t] { + ProcessView.filterAndSort(sample, search: search, by: column, ascending: ascending).map(\.pid) + } + + func testFigureColumnsSortBothWays() { + XCTAssertEqual(pids(.compressed, ascending: false), [200, 300, 1, 400]) // ties fall back to PID + XCTAssertEqual(pids(.compressed, ascending: true), [1, 400, 300, 200]) + XCTAssertEqual(pids(.memory, ascending: false), [200, 300, 400, 1]) + XCTAssertEqual(pids(.cpu, ascending: false), [400, 300, 200, 1]) + } + + func testNameSortIsCaseInsensitive() { + XCTAssertEqual(pids(.name, ascending: true), [300, 1, 200, 400]) + XCTAssertEqual(pids(.name, ascending: false), [400, 200, 1, 300]) + } + + func testSearchMatchesNameOrPid() { + XCTAssertEqual(pids(.pid, ascending: true, search: "fin"), [300]) + XCTAssertEqual(pids(.pid, ascending: true, search: "40"), [400]) + XCTAssertEqual(pids(.pid, ascending: true, search: ""), [1, 200, 300, 400]) + } +} + +/// Drives the real coordinator against a real (window-less) NSTableView. +final class ProcessTableCoordinatorTests: XCTestCase { + + private final class Harness { + var selection: Set = [] + var sort: (ProcessSortColumn, Bool) = (.cpu, false) + var opened: [pid_t] = [] + var terminated: [pid_t] = [] + var deleteKeyPresses = 0 + let table = ProcessNSTableView() + var coordinator: ProcessTableView.Coordinator! + + func view(_ rows: [ProcessItem]) -> ProcessTableView { + ProcessTableView( + processes: rows, + selection: Binding(get: { self.selection }, set: { self.selection = $0 }), + sortColumn: sort.0, sortAscending: sort.1, + onSort: { self.sort = ($0, $1) }, + actions: ProcessTableActions(open: { self.opened.append($0.pid) }, + terminate: { self.terminated.append($0.pid) }, + forceKill: { _ in }, suspend: { _ in }, resume: { _ in }, + deleteKey: { self.deleteKeyPresses += 1 })) + } + + /// Mirrors makeNSView + updateNSView. + func show(_ rows: [ProcessItem]) { + let view = view(rows) + if coordinator == nil { + coordinator = view.makeCoordinator() + for column in ProcessColumn.allCases { + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue))) + } + table.dataSource = coordinator + table.delegate = coordinator + table.onDelete = { self.deleteKeyPresses += 1 } + coordinator.tableView = table + } + coordinator.parent = view + coordinator.update() + } + } + + private func item(_ pid: pid_t, _ name: String) -> ProcessItem { + ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: 0, cpuUsageTotal: 0, memoryUsage: 0, + threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil) + } + + @MainActor + func testSelectionFollowsTheProcessWhenRowsReorder() { + let h = Harness() + let a = item(10, "a"), b = item(20, "b"), c = item(30, "c") + h.show([a, b, c]) + XCTAssertEqual(h.table.numberOfRows, 3, "first load must not double the row count") + + h.table.selectRowIndexes([1], byExtendingSelection: false) // user clicks "b" + XCTAssertEqual(h.selection, [20]) + + h.show([c, a, b]) // re-sorted by a refresh + XCTAssertEqual(h.table.selectedRowIndexes, [2]) + XCTAssertEqual(h.selection, [20], "syncing the table must not write back into the binding") + + h.show([c, a]) // "b" exited + XCTAssertEqual(h.table.numberOfRows, 2) + XCTAssertTrue(h.table.selectedRowIndexes.isEmpty) + } + + @MainActor + func testHeaderClickReportsNextSort() { + let h = Harness() + h.show([item(1, "a")]) + let memory = h.table.tableColumn(withIdentifier: NSUserInterfaceItemIdentifier("memory"))! + h.coordinator.tableView(h.table, didClick: memory) + XCTAssertTrue(h.sort == (.memory, false)) + h.show([item(1, "a")]) // SwiftUI re-renders with the new sort + h.coordinator.tableView(h.table, didClick: memory) + XCTAssertTrue(h.sort == (.memory, true)) + } + + @MainActor + func testContextMenuUsesSelectedRowWithoutRightClick() throws { + let h = Harness() + h.show([item(10, "a"), item(20, "b")]) + + let empty = NSMenu() + h.coordinator.menuNeedsUpdate(empty) + XCTAssertTrue(empty.items.isEmpty, "no click and no selection: nothing to act on") + + h.table.selectRowIndexes([1], byExtendingSelection: false) + let menu = NSMenu() + h.coordinator.menuNeedsUpdate(menu) + XCTAssertEqual(menu.items.filter { !$0.isSeparatorItem }.map(\.title), + ["Get Info (⌘I)", "Terminate (⌫)", "Force Kill (⌘⌫)", "Suspend", "Resume", "Copy PID"]) + + for title in ["Get Info (⌘I)", "Terminate (⌫)"] { + let entry = try XCTUnwrap(menu.items.first { $0.title == title }) + _ = (entry.target as? NSObject)?.perform(entry.action, with: entry) + } + XCTAssertEqual(h.opened, [20]) + XCTAssertEqual(h.terminated, [20]) + } + + @MainActor + func testDeleteKeyTriggersTerminateButCommandDeleteDoesNot() throws { + let h = Harness() + h.show([item(1, "a")]) + func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags) throws -> NSEvent { + try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, + windowNumber: 0, context: nil, characters: "\u{8}", + charactersIgnoringModifiers: "\u{8}", isARepeat: false, keyCode: code)) + } + h.table.keyDown(with: try key(51, [])) + h.table.keyDown(with: try key(117, [.function])) // forward delete + XCTAssertEqual(h.deleteKeyPresses, 2) + h.table.keyDown(with: try key(51, [.command])) // ⌘⌫ belongs to Force Kill + XCTAssertEqual(h.deleteKeyPresses, 2) + } +}