From c50443bdbc9f8a1371d067e485090bb23f3adc21 Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Mon, 5 Oct 2026 22:06:18 +0200 Subject: [PATCH] Draw the process list with NSTableView instead of SwiftUI's Table On macOS 26 SwiftUI's Table retained AutoLayout and view-graph objects on every data update, on top of an NSHostingView per cell: tens of MB an hour while the window was visible. 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 are reused AppKit views and an update rewrites only the changed fields of rows on screen. Column widths and order are remembered; the context menu, double-click, Delete key and sort directions match the old table. Co-Authored-By: Claude Opus 5.5 --- TopManager.xcodeproj/project.pbxproj | 8 + .../Views/Processes/ProcessTableView.swift | 477 ++++++++++++++++++ TopManager/Views/Processes/ProcessView.swift | 298 ++--------- TopManagerTests/ProcessTableTests.swift | 267 ++++++++++ 4 files changed, 794 insertions(+), 256 deletions(-) create mode 100644 TopManager/Views/Processes/ProcessTableView.swift create mode 100644 TopManagerTests/ProcessTableTests.swift 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) + } +}