From d76c6409fa4fc931f4b1f96d7ac893e47fdd270f Mon Sep 17 00:00:00 2001 From: Lakr233 Date: Fri, 25 Sep 2026 19:57:26 +0700 Subject: [PATCH] Process list in columns, with headings that sort (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list read as a stack of sentences: a name, then "PID · user · N threads" under it, with CPU and memory piled at the right edge. Fine for finding one process, useless for the thing a process viewer is for — running an eye down the whole system and seeing which one is misbehaving. Each row is now one line: icon and name, then PID, CPU, memory and threads, each right-aligned in a column. A column is as wide as the widest figure it can hold at the current text size, measured once in ProcessListMetrics and shared by every row and the headings, so figures line up whatever the row holds. The headings take their edges from a laid-out row rather than their own margins, so they sit over the figures wherever the table puts the header. Where a row is too narrow, threads go first, then PID; CPU always stays. At the accessibility text sizes the figures stack under the name. CPU shows orange from half a core and red from nine tenths, so a process spinning a core stands out; idle stays secondary. The user moves off the row (it is in the details and VoiceOver still reads it), and so does the disclosure chevron, which cost the name column its width. A heading sorts by its column, tinted with an arrow like a detail table's. Threads is a new sort order to match. Rows are laid out by hand at a fixed height, so a live sample only sets text, and the table never measures a row. German "Memory" becomes "Speicher", as Activity Monitor has it: "Arbeitsspeicher" was wider than any figure under it. Co-Authored-By: Claude Opus 5.5 --- Inspector/InspectorFonts.swift | 10 +- Inspector/InspectorFormatting.swift | 27 +++ Inspector/ProcessListColumns.swift | 181 ++++++++++++++++++ Inspector/ProcessListHeaderView.swift | 209 ++++++++++++++++++++ Inspector/ProcessListModel.swift | 7 + Inspector/ProcessListViewController.swift | 95 ++++++++-- Inspector/ProcessRowCell.swift | 220 +++++++++++++++++----- Inspector/Resources/Localizable.xcstrings | 2 +- 8 files changed, 677 insertions(+), 74 deletions(-) create mode 100644 Inspector/ProcessListColumns.swift create mode 100644 Inspector/ProcessListHeaderView.swift diff --git a/Inspector/InspectorFonts.swift b/Inspector/InspectorFonts.swift index db87d3f..0f246c5 100644 --- a/Inspector/InspectorFonts.swift +++ b/Inspector/InspectorFonts.swift @@ -9,11 +9,13 @@ extension UIFont { // A Dynamic Type font in a weight or design preferredFont(forTextStyle:) // doesn't offer. Sized from the default category and then scaled, so the - // person's text size is applied once, not twice. + // person's text size is applied once, not twice. Pass traits to size it + // for a text size other than the app's current one. static func inspector( _ style: TextStyle, weight: Weight = .regular, - design: InspectorDesign = .standard + design: InspectorDesign = .standard, + compatibleWith traits: UITraitCollection? = nil ) -> UIFont { let size = UIFont.preferredFont( forTextStyle: style, @@ -25,6 +27,8 @@ extension UIFont { case .monospacedDigit: base = .monospacedDigitSystemFont(ofSize: size, weight: weight) case .monospaced: base = .monospacedSystemFont(ofSize: size, weight: weight) } - return UIFontMetrics(forTextStyle: style).scaledFont(for: base) + let metrics = UIFontMetrics(forTextStyle: style) + guard let traits else { return metrics.scaledFont(for: base) } + return metrics.scaledFont(for: base, compatibleWith: traits) } } diff --git a/Inspector/InspectorFormatting.swift b/Inspector/InspectorFormatting.swift index 7736f12..a72e651 100644 --- a/Inspector/InspectorFormatting.swift +++ b/Inspector/InspectorFormatting.swift @@ -5,6 +5,33 @@ enum InspectorFormat { ByteCountFormatter.string(fromByteCount: Int64(clamping: value), countStyle: .memory) } + // The process list's memory column. ByteCountFormatter keeps a unit until + // the next one is a whole step away, so 1,000 MB to 1,023.9 MB come out + // four digits wide; the column is sized for three, so those move up a + // unit and read 0.98 GB. + static func memoryColumn(_ value: UInt64) -> String { + let kibibyte: UInt64 = 1 << 10 + let mebibyte = kibibyte << 10 + let gibibyte = mebibyte << 10 + if value >= 1000 * mebibyte, value < gibibyte { + return gigabyteFormatter.string(fromByteCount: Int64(value)) + } + if value >= 1000 * kibibyte, value < mebibyte { + return megabyteFormatter.string(fromByteCount: Int64(value)) + } + return memoryBytes(value) + } + + private static let gigabyteFormatter = memoryFormatter(unit: .useGB) + private static let megabyteFormatter = memoryFormatter(unit: .useMB) + + private static func memoryFormatter(unit: ByteCountFormatter.Units) -> ByteCountFormatter { + let formatter = ByteCountFormatter() + formatter.countStyle = .memory + formatter.allowedUnits = unit + return formatter + } + static func dataBytes(_ value: UInt64) -> String { ByteCountFormatter.string(fromByteCount: Int64(clamping: value), countStyle: .file) } diff --git a/Inspector/ProcessListColumns.swift b/Inspector/ProcessListColumns.swift new file mode 100644 index 0000000..9976673 --- /dev/null +++ b/Inspector/ProcessListColumns.swift @@ -0,0 +1,181 @@ +import UIKit + +// The figures a process row shows after its name, in the order they appear. +enum ProcessColumn: CaseIterable { + case pid + case cpu + case memory + case threads + + // When a row is too narrow for all of them, the least telling go first. + static let byPriority: [ProcessColumn] = [.cpu, .memory, .pid, .threads] + + // Headings are short, since a column is at least as wide as its + // heading; VoiceOver reads the sort order's longer name instead. + var title: String { + switch self { + case .pid: String(localized: "PID") + case .cpu: String(localized: "CPU") + case .memory: String(localized: "Memory") + case .threads: String(localized: "Threads") + } + } + + var sortOrder: ProcessSortOrder { + switch self { + case .pid: .pid + case .cpu: .cpu + case .memory: .memory + case .threads: .threads + } + } +} + +// Fonts and column widths for one text size, shared by every row and the +// heading above them. A column is as wide as the widest figure it can hold, +// measured once here — never from the row being drawn — so the figures line +// up from row to row and under their headings. At the accessibility sizes a +// row has no room for columns, and stacks its figures under the name. +final class ProcessListMetrics { + let isStacked: Bool + let nameFont: UIFont + let valueFont: UIFont + let titleFont: UIFont + let summaryFont: UIFont + let iconSize: CGFloat + let iconSpacing: CGFloat + let columnSpacing: CGFloat + let rowHeight: CGFloat + let titleRowHeight: CGFloat + private let minimumNameWidth: CGFloat + private let widths: [ProcessColumn: CGFloat] + + private static var cache: [UIContentSizeCategory: ProcessListMetrics] = [:] + + static func metrics(for category: UIContentSizeCategory) -> ProcessListMetrics { + // A view outside a window reports no text size; the app's stands in. + let category = category == .unspecified + ? UIApplication.shared.preferredContentSizeCategory + : category + if let cached = cache[category] { return cached } + let metrics = ProcessListMetrics(category: category) + cache[category] = metrics + return metrics + } + + private init(category: UIContentSizeCategory) { + let traits = UITraitCollection(preferredContentSizeCategory: category) + isStacked = category.isAccessibilityCategory + nameFont = .preferredFont(forTextStyle: isStacked ? .body : .subheadline, compatibleWith: traits) + valueFont = .inspector(.footnote, design: .monospacedDigit, compatibleWith: traits) + titleFont = .inspector(.caption1, weight: .semibold, compatibleWith: traits) + summaryFont = .preferredFont(forTextStyle: .footnote, compatibleWith: traits) + iconSize = isStacked ? 36 : 28 + iconSpacing = 10 + columnSpacing = (valueFont.pointSize * 0.75).rounded() + minimumNameWidth = (nameFont.pointSize * 5.5).rounded() + titleRowHeight = ceil(titleFont.lineHeight) + 12 + + let valueLine = ceil(valueFont.lineHeight) + let nameLine = ceil(nameFont.lineHeight) + rowHeight = isStacked + // The name, then CPU and memory, then PID and threads. + ? max(iconSize, nameLine + 2 * (Self.lineSpacing + valueLine)) + 2 * Self.stackedPadding + : max(iconSize, nameLine, valueLine) + 2 * Self.rowPadding + + // The widest figure each column can hold. Digits are all one width in + // this font, so eights stand for any number of that length. + let samples: [ProcessColumn: [String]] = [ + .pid: ["88888"], + .cpu: [InspectorFormat.percent(8.888)], + .memory: [ + InspectorFormat.memoryColumn((1000 << 20) - (100 << 10)), + InspectorFormat.memoryColumn((100 << 30) - (10 << 20)), + InspectorFormat.memoryColumn(999 << 10), + ], + .threads: ["888"], + ] + // A heading also has to fit the arrow it carries while sorted. + let arrow = UIImage(systemName: "chevron.down", withConfiguration: Self.arrowConfiguration) + let arrowWidth = ceil(arrow?.size.width ?? 8) + var widths: [ProcessColumn: CGFloat] = [:] + for column in ProcessColumn.allCases { + var width = Self.width(of: column.title, in: titleFont) + arrowWidth + for sample in samples[column, default: []] { + width = max(width, Self.width(of: sample, in: valueFont)) + } + widths[column] = width + } + self.widths = widths + } + + // The sorted heading's arrow, the size a detail table's is. + static let arrowConfiguration = UIImage.SymbolConfiguration(pointSize: 8, weight: .bold) + static let rowPadding: CGFloat = 8 + static let stackedPadding: CGFloat = 10 + static let lineSpacing: CGFloat = 2 + + func width(of column: ProcessColumn) -> CGFloat { + widths[column, default: 0] + } + + // The columns that fit beside a name in a row this wide, in display order. + // CPU always shows; each further column must leave the name its minimum. + func columns(fittingWidth contentWidth: CGFloat) -> [ProcessColumn] { + var remaining = contentWidth - iconSize - iconSpacing + var shown: Set = [] + for column in ProcessColumn.byPriority { + let needed = width(of: column) + columnSpacing + guard shown.isEmpty || remaining - needed >= minimumNameWidth else { break } + remaining -= needed + shown.insert(column) + } + return ProcessColumn.allCases.filter(shown.contains) + } + + // Where each part of a columnar row goes, across `content` (a row's + // content area, between its margins). Every rect spans the full height of + // `content`; callers place text within it. Right-to-left mirrors the lot. + func columnLayout(in content: CGRect, isRightToLeft: Bool) -> ProcessListColumnLayout { + func span(from minX: CGFloat, to maxX: CGFloat) -> CGRect { + CGRect(x: minX, y: content.minY, width: max(0, maxX - minX), height: content.height) + } + var layout = ProcessListColumnLayout() + // Figures fill in from the trailing edge; the name takes what's left. + var trailing = content.maxX + for column in columns(fittingWidth: content.width).reversed() { + let leading = trailing - width(of: column) + layout.columns.insert((column, span(from: leading, to: trailing)), at: 0) + trailing = leading - columnSpacing + } + layout.icon = span(from: content.minX, to: content.minX + iconSize) + layout.name = span(from: layout.icon.maxX + iconSpacing, to: trailing) + layout.nameColumn = span(from: content.minX, to: trailing) + if isRightToLeft { layout.mirror(in: content) } + return layout + } + + private static func width(of text: String, in font: UIFont) -> CGFloat { + ceil((text as NSString).size(withAttributes: [.font: font]).width) + } +} + +struct ProcessListColumnLayout { + var icon = CGRect.zero + var name = CGRect.zero + // The icon and the name together, which the Name heading spans. + var nameColumn = CGRect.zero + var columns: [(column: ProcessColumn, frame: CGRect)] = [] + + fileprivate mutating func mirror(in content: CGRect) { + func mirrored(_ rect: CGRect) -> CGRect { + var rect = rect + rect.origin.x = content.minX + content.maxX - rect.maxX + return rect + } + icon = mirrored(icon) + name = mirrored(name) + nameColumn = mirrored(nameColumn) + columns = columns.map { ($0.column, mirrored($0.frame)) } + } +} diff --git a/Inspector/ProcessListHeaderView.swift b/Inspector/ProcessListHeaderView.swift new file mode 100644 index 0000000..88aafa0 --- /dev/null +++ b/Inspector/ProcessListHeaderView.swift @@ -0,0 +1,209 @@ +import UIKit + +// Above the rows: what the list holds ("120 processes · Paused"), then a +// heading over each column. A heading sorts the list by its column, and the +// one it is sorted by is tinted and carries an arrow, as in a detail table. +final class ProcessListHeaderView: UITableViewHeaderFooterView { + static let reuseIdentifier = "header" + + var sort: (ProcessSortOrder) -> Void { + get { headings.sort } + set { headings.sort = newValue } + } + + private let summaryLabel = UILabel() + private let headings = ProcessColumnHeadingsView() + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + summaryLabel.textColor = .secondaryLabel + summaryLabel.numberOfLines = 0 + summaryLabel.accessibilityTraits = .header + + let stack = UIStackView(arrangedSubviews: [summaryLabel, headings]) + stack.axis = .vertical + stack.spacing = 2 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + let margins = contentView.layoutMarginsGuide + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: margins.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: margins.trailingAnchor), + stack.topAnchor.constraint(equalTo: margins.topAnchor), + stack.bottomAnchor.constraint(equalTo: margins.bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func configure(summary: String, sortOrder: ProcessSortOrder, metrics: ProcessListMetrics) { + summaryLabel.text = summary + summaryLabel.font = metrics.summaryFont + // Stacked rows have no columns to head; the menu still sorts them. + if headings.isHidden != metrics.isStacked { + headings.isHidden = metrics.isStacked + } + headings.configure(sortOrder: sortOrder, metrics: metrics) + } + + /// Lines the headings up with the rows below: `insets` are the distances + /// from the sides of `table` to a row's content area. + func alignColumns(to insets: UIEdgeInsets, in table: UIView) { + headings.align(to: insets, in: table) + } +} + +private final class ProcessColumnHeadingsView: UIView { + var sort: (ProcessSortOrder) -> Void = { _ in } + + private struct Heading { + let order: ProcessSortOrder + let column: ProcessColumn? + let button = UIButton(type: .system) + } + + // The name heading first, then the figures, in reading order. + private let headings: [Heading] = [Heading(order: .name, column: nil)] + + ProcessColumn.allCases.map { Heading(order: $0.sortOrder, column: $0) } + private var metrics: ProcessListMetrics? + private var sortOrder: ProcessSortOrder? + private var rowInsets: UIEdgeInsets? + private weak var table: UIView? + + override init(frame: CGRect) { + super.init(frame: frame) + for heading in headings { + let button = heading.button + let title = heading.column?.title ?? String(localized: "Name") + button.setTitle(title, for: .normal) + button.titleLabel?.lineBreakMode = .byClipping + button.addTarget(self, action: #selector(headingTapped(_:)), for: .touchUpInside) + // Voice Control accepts the heading as written; VoiceOver reads + // the sort order's full name, as the Sort By menu words it. + button.accessibilityLabel = heading.order.label + button.accessibilityUserInputLabels = [title, heading.order.label] + addSubview(button) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: metrics?.titleRowHeight ?? 0) + } + + func configure(sortOrder: ProcessSortOrder, metrics: ProcessListMetrics) { + if metrics !== self.metrics { + self.metrics = metrics + for heading in headings { + heading.button.titleLabel?.font = metrics.titleFont + } + invalidateIntrinsicContentSize() + setNeedsLayout() + } + guard sortOrder != self.sortOrder else { return } + self.sortOrder = sortOrder + applySortOrder() + } + + func align(to insets: UIEdgeInsets, in table: UIView) { + guard insets != rowInsets || table !== self.table else { return } + rowInsets = insets + self.table = table + setNeedsLayout() + } + + override func tintColorDidChange() { + super.tintColorDidChange() + applySortOrder() + } + + private func applySortOrder() { + for heading in headings { + let isSorted = heading.order == sortOrder + let button = heading.button + button.setImage( + isSorted + ? UIImage( + systemName: heading.order.isAscending ? "chevron.up" : "chevron.down", + withConfiguration: ProcessListMetrics.arrowConfiguration + ) + : nil, + for: .normal + ) + button.tintColor = isSorted ? tintColor : .secondaryLabel + // The arrow is the only cue for the direction, and it is drawn, + // not spoken. Each column sorts one way only, so a tap on the + // sorted heading changes nothing and earns no hint. + button.accessibilityValue = isSorted + ? (heading.order.isAscending + ? String(localized: "Sorted ascending") + : String(localized: "Sorted descending")) + : nil + button.accessibilityHint = isSorted + ? nil + : String(localized: "Sorts the list by this column") + button.accessibilityTraits = isSorted ? [.button, .selected] : .button + } + setNeedsLayout() + } + + override func layoutSubviews() { + super.layoutSubviews() + guard let metrics else { return } + // The rows' content area, carried over from the table, so the + // headings follow the rows whatever margins this header was given. + var content = bounds + if let table, let rowInsets { + let tableBounds = table.convert(table.bounds, to: self) + content.origin.x = tableBounds.minX + rowInsets.left + content.size.width = max(0, tableBounds.width - rowInsets.left - rowInsets.right) + } + let isRightToLeft = effectiveUserInterfaceLayoutDirection == .rightToLeft + let layout = metrics.columnLayout(in: content, isRightToLeft: isRightToLeft) + for heading in headings { + let button = heading.button + let frame: CGRect? + if let column = heading.column { + frame = layout.columns.first { $0.column == column }?.frame + } else { + frame = layout.nameColumn + } + guard let frame else { + button.isHidden = true + continue + } + button.isHidden = false + button.frame = frame + // The name heading starts where the icons do, its arrow after + // it. A figure's heading ends where its figures end, so its arrow + // goes in front. + let isLeading = heading.column == nil + button.contentHorizontalAlignment = isLeading == isRightToLeft ? .right : .left + button.semanticContentAttribute = isLeading == isRightToLeft + ? .forceLeftToRight + : .forceRightToLeft + } + } + + @objc private func headingTapped(_ sender: UIButton) { + guard let heading = headings.first(where: { $0.button === sender }) else { return } + sort(heading.order) + } +} + +private extension ProcessSortOrder { + // The direction areInOrder sorts in, which the arrow shows. + var isAscending: Bool { + switch self { + case .pid, .name: true + case .cpu, .memory, .threads: false + } + } +} diff --git a/Inspector/ProcessListModel.swift b/Inspector/ProcessListModel.swift index e553f8d..74ecb67 100644 --- a/Inspector/ProcessListModel.swift +++ b/Inspector/ProcessListModel.swift @@ -15,6 +15,7 @@ struct ProcessRow: Identifiable, Equatable { enum ProcessSortOrder: String, CaseIterable, Identifiable { case cpu = "CPU" case memory = "Memory" + case threads = "Threads" case pid = "PID" case name = "Name" @@ -26,6 +27,7 @@ enum ProcessSortOrder: String, CaseIterable, Identifiable { switch self { case .cpu: String(localized: "CPU Usage") case .memory: String(localized: "Memory Used") + case .threads: String(localized: "Threads") case .pid: String(localized: "PID") case .name: String(localized: "Name") } @@ -57,6 +59,11 @@ enum ProcessSortOrder: String, CaseIterable, Identifiable { case .orderedDescending: return false case .orderedSame: return lhs.record.pid < rhs.record.pid } + case .threads: + if lhs.record.threadCount != rhs.record.threadCount { + return lhs.record.threadCount > rhs.record.threadCount + } + return lhs.record.pid < rhs.record.pid } } } diff --git a/Inspector/ProcessListViewController.swift b/Inspector/ProcessListViewController.swift index 90c3ad0..d6f2b9a 100644 --- a/Inspector/ProcessListViewController.swift +++ b/Inspector/ProcessListViewController.swift @@ -23,6 +23,14 @@ final class ProcessListViewController: UITableViewController, UISearchResultsUpd // A connection that comes up quickly shows a blank list, not a flash of // "Connecting…"; one that takes a while shows it long enough to be read. private let connectingIndicator = DelayedLoadingIndicator() + // Fonts and column widths for the current text size, shared by every row + // and the headings so the columns line up. + private lazy var metrics = ProcessListMetrics.metrics( + for: traitCollection.preferredContentSizeCategory + ) + // Where a row's content starts and ends, as distances from the table's + // sides, taken from a laid-out row for the headings to follow. + private var columnInsets: UIEdgeInsets? init(model: ProcessListModel) { self.model = model @@ -55,13 +63,17 @@ final class ProcessListViewController: UITableViewController, UISearchResultsUpd clearsSelectionOnViewWillAppear = false tableView.register(ProcessRowCell.self, forCellReuseIdentifier: ProcessRowCell.reuseIdentifier) + tableView.estimatedRowHeight = metrics.rowHeight dataSource = ProcessListDataSource(tableView: tableView) { [weak self] tableView, indexPath, identity in let cell = tableView.dequeueReusableCell( withIdentifier: ProcessRowCell.reuseIdentifier, for: indexPath ) - if let row = self?.model.row(for: identity) { - (cell as? ProcessRowCell)?.configure(with: row) + if let self, let cell = cell as? ProcessRowCell { + cell.didLayout = { [weak self] in self?.alignColumnHeadings(with: $0) } + if let row = model.row(for: identity) { + cell.configure(with: row, metrics: metrics) + } } return cell } @@ -171,11 +183,10 @@ final class ProcessListViewController: UITableViewController, UISearchResultsUpd guard let indexPath = tableView.indexPath(for: cell), let identity = dataSource.itemIdentifier(for: indexPath), let row = model.row(for: identity) else { continue } - cell.configure(with: row) + cell.configure(with: row, metrics: metrics) } - if let header = tableView.headerView(forSection: 0) { - header.textLabel?.text = processHeader(visibleCount: rows.count) - header.setNeedsLayout() + if let header = tableView.headerView(forSection: 0) as? ProcessListHeaderView { + configure(header) } statsButton.isEnabled = !model.rows.isEmpty renderLiveUpdatesItem() @@ -256,26 +267,70 @@ final class ProcessListViewController: UITableViewController, UISearchResultsUpd // MARK: Table view + // A header view of its own rather than titleForHeaderInSection, which + // uppercases the text on older systems and has no room for headings. override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let identifier = "header" - return tableView.dequeueReusableHeaderFooterView(withIdentifier: identifier) - ?? UITableViewHeaderFooterView(reuseIdentifier: identifier) + let identifier = ProcessListHeaderView.reuseIdentifier + let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: identifier) + as? ProcessListHeaderView ?? ProcessListHeaderView(reuseIdentifier: identifier) + header.sort = { [weak self] order in self?.model.sortOrder = order } + configure(header) + return header } - // Set here rather than through titleForHeaderInSection, which uppercases - // the text on older systems — once, so later live updates wouldn't match. - override func tableView( - _ tableView: UITableView, - willDisplayHeaderView view: UIView, - forSection section: Int - ) { - (view as? UITableViewHeaderFooterView)?.textLabel?.text = processHeader( - visibleCount: model.visibleRows.count + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + UITableView.automaticDimension + } + + // Every row is one height for a given text size, so the table never + // measures a row, however many there are. + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + metrics.rowHeight + } + + private func configure(_ header: ProcessListHeaderView) { + header.configure( + summary: processHeader(visibleCount: model.visibleRows.count), + sortOrder: model.sortOrder, + metrics: metrics ) + if let columnInsets { + header.alignColumns(to: columnInsets, in: tableView) + } } - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - UITableView.automaticDimension + // The headings take their positions from a row that has just laid out, + // wherever the table put the header. A row mid-swipe is out of place, so + // it doesn't count. + private func alignColumnHeadings(with cell: ProcessRowCell) { + guard !tableView.isEditing, cell.window != nil else { return } + let content = cell.contentView.convert(cell.columnsRect, to: tableView) + let insets = UIEdgeInsets( + top: 0, + left: content.minX - tableView.bounds.minX, + bottom: 0, + right: tableView.bounds.maxX - content.maxX + ) + guard insets != columnInsets else { return } + columnInsets = insets + (tableView.headerView(forSection: 0) as? ProcessListHeaderView)? + .alignColumns(to: insets, in: tableView) + } + + // A new text size brings new fonts and column widths, and a new height + // for every row. + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard isViewLoaded else { return } + let category = traitCollection.preferredContentSizeCategory + guard category != previousTraitCollection?.preferredContentSizeCategory else { return } + let metrics = ProcessListMetrics.metrics(for: category) + guard metrics !== self.metrics else { return } + self.metrics = metrics + tableView.estimatedRowHeight = metrics.rowHeight + render() + tableView.beginUpdates() + tableView.endUpdates() } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { diff --git a/Inspector/ProcessRowCell.swift b/Inspector/ProcessRowCell.swift index 81eceb4..5de81b1 100644 --- a/Inspector/ProcessRowCell.swift +++ b/Inspector/ProcessRowCell.swift @@ -1,60 +1,52 @@ import UIKit +// One process as a line of columns: icon and name, then figures at the +// widths ProcessListMetrics measured, so they line up under the headings. +// Laid out by hand: a live sample redraws every visible row, and fixed +// frames make that a matter of setting text. final class ProcessRowCell: UITableViewCell { static let reuseIdentifier = "process" + /// Called after every layout, with the row's geometry settled, so the + /// headings above can line up with it. + var didLayout: (ProcessRowCell) -> Void = { _ in } + private let iconView = ProcessApplicationIconView() private let nameLabel = UILabel() - private let subtitleLabel = UILabel() + private let pidLabel = UILabel() private let cpuLabel = UILabel() private let memoryLabel = UILabel() + private let threadsLabel = UILabel() + private var metrics: ProcessListMetrics? + + // CPU use, as a fraction of one core, from which the figure stops + // reading as idle, then as ordinary, then as busy. A process spinning a + // whole core, which is what a stuck one does, shows red. + private static let activeCPU = 0.005 + private static let busyCPU = 0.5 + private static let saturatedCPU = 0.9 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - accessoryType = .disclosureIndicator // One element for the whole row, labelled in configure(with:). Left to - // itself the cell strings the four labels together, so the subtitle's - // separators are spoken and the two figures arrive with no unit. + // itself the cell reads each figure as a separate bare number. isAccessibilityElement = true - nameLabel.font = .preferredFont(forTextStyle: .body) - subtitleLabel.font = .preferredFont(forTextStyle: .footnote) - subtitleLabel.textColor = .secondaryLabel - cpuLabel.font = .inspector(.body, design: .monospacedDigit) - memoryLabel.font = .inspector(.footnote, design: .monospacedDigit) - memoryLabel.textColor = .secondaryLabel - for label in [nameLabel, subtitleLabel, cpuLabel, memoryLabel] { - label.adjustsFontForContentSizeCategory = true + // The middle goes rather than the end: helpers share long prefixes, + // like com.apple.WebKit.WebContent and com.apple.WebKit.Networking. + nameLabel.lineBreakMode = .byTruncatingMiddle + pidLabel.textColor = .secondaryLabel + threadsLabel.textColor = .secondaryLabel + // A figure wider than its column's sample (a thousand threads) shrinks + // to fit rather than losing digits. + for label in [pidLabel, cpuLabel, memoryLabel, threadsLabel] { + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.7 + label.baselineAdjustment = .alignBaselines } - // The numbers keep their width; a long name gives way instead. - for label in [cpuLabel, memoryLabel] { - label.textAlignment = .natural - label.setContentCompressionResistancePriority(.required, for: .horizontal) - label.setContentHuggingPriority(.required, for: .horizontal) + for view in [iconView, nameLabel, pidLabel, cpuLabel, memoryLabel, threadsLabel] { + contentView.addSubview(view) } - - let names = UIStackView(arrangedSubviews: [nameLabel, subtitleLabel]) - names.axis = .vertical - names.spacing = 2 - let numbers = UIStackView(arrangedSubviews: [cpuLabel, memoryLabel]) - numbers.axis = .vertical - numbers.alignment = .trailing - numbers.spacing = 2 - let content = UIStackView(arrangedSubviews: [iconView, names, numbers]) - content.alignment = .center - content.spacing = 10 - content.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(content) - - let margins = contentView.layoutMarginsGuide - NSLayoutConstraint.activate([ - iconView.widthAnchor.constraint(equalToConstant: ProcessApplicationIconView.size), - iconView.heightAnchor.constraint(equalToConstant: ProcessApplicationIconView.size), - content.leadingAnchor.constraint(equalTo: margins.leadingAnchor), - content.trailingAnchor.constraint(equalTo: margins.trailingAnchor), - content.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), - content.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), - ]) } @available(*, unavailable) @@ -62,28 +54,156 @@ final class ProcessRowCell: UITableViewCell { fatalError("init(coder:) is not supported") } - func configure(with row: ProcessRow) { + /// The row's content area, between its margins, in contentView's space. + var columnsRect: CGRect { + contentView.bounds.inset(by: contentView.layoutMargins) + } + + func configure(with row: ProcessRow, metrics: ProcessListMetrics) { + if metrics !== self.metrics { + self.metrics = metrics + nameLabel.font = metrics.nameFont + for label in [pidLabel, cpuLabel, memoryLabel, threadsLabel] { + label.font = metrics.valueFont + } + setNeedsLayout() + } iconView.executablePath = row.isApp ? row.record.executablePath : nil nameLabel.text = row.displayName - let details = [ - "PID \(row.record.pid)", - InspectorFormat.userName(row.record.userID), - String(localized: "\(Int(row.record.threadCount)) threads"), - ] - subtitleLabel.text = details.joined(separator: " · ") + let pid = "PID \(row.record.pid)" + let threads = String(localized: "\(Int(row.record.threadCount)) threads") let cpu = InspectorFormat.percent(row.cpuFraction) - let memory = InspectorFormat.memoryBytes(row.record.physicalFootprint) + let memory = InspectorFormat.memoryColumn(row.record.physicalFootprint) + // Under a heading a column needs only the number; stacked, there is + // no heading, so each figure says what it is. + pidLabel.text = metrics.isStacked ? pid : "\(row.record.pid)" + threadsLabel.text = metrics.isStacked ? threads : "\(row.record.threadCount)" cpuLabel.text = cpu - cpuLabel.textColor = row.cpuFraction > 0.005 ? .label : .secondaryLabel + cpuLabel.textColor = Self.cpuColor(row.cpuFraction) memoryLabel.text = memory + // Stacked figures sit side by side at their own widths. + if metrics.isStacked { setNeedsLayout() } + // What the row is goes in the label, what it currently measures in the // value, so a live sample re-announces the figures without repeating // the name. The numbers are named because on their own they are two // bare quantities. - accessibilityLabel = ([row.displayName] + details).joined(separator: ", ") + accessibilityLabel = [ + row.displayName, + pid, + InspectorFormat.userName(row.record.userID), + threads, + ].joined(separator: ", ") accessibilityValue = [ "\(String(localized: "CPU")) \(cpu)", "\(String(localized: "Memory")) \(memory)", ].joined(separator: ", ") } + + private static func cpuColor(_ fraction: Double) -> UIColor { + if fraction >= saturatedCPU { return .systemRed } + if fraction >= busyCPU { return .systemOrange } + if fraction >= activeCPU { return .label } + return .secondaryLabel + } + + override func layoutSubviews() { + super.layoutSubviews() + guard let metrics else { return } + if metrics.isStacked { + layoutStacked(metrics) + } else { + layoutColumns(metrics) + } + didLayout(self) + } + + private func layoutColumns(_ metrics: ProcessListMetrics) { + var content = columnsRect + content.origin.y = contentView.bounds.minY + content.size.height = contentView.bounds.height + let layout = metrics.columnLayout( + in: content, + isRightToLeft: effectiveUserInterfaceLayoutDirection == .rightToLeft + ) + iconView.frame = centered(height: metrics.iconSize, in: layout.icon) + // Names and figures differ in size, so they share a baseline rather + // than a center line. + nameLabel.frame = centered(height: ceil(metrics.nameFont.lineHeight), in: layout.name) + let baseline = nameLabel.frame.minY + metrics.nameFont.ascender + let valueY = pixelAligned(baseline - metrics.valueFont.ascender) + let valueHeight = ceil(metrics.valueFont.lineHeight) + let alignment: NSTextAlignment = effectiveUserInterfaceLayoutDirection == .rightToLeft + ? .left + : .right + var hidden = Set(ProcessColumn.allCases) + for (column, frame) in layout.columns { + let label = label(for: column) + label.frame = CGRect(x: frame.minX, y: valueY, width: frame.width, height: valueHeight) + label.textAlignment = alignment + hidden.remove(column) + } + for column in ProcessColumn.allCases { + label(for: column).isHidden = hidden.contains(column) + } + } + + // The name on its own line, then CPU and memory, then PID and threads, + // each figure as wide as its text. + private func layoutStacked(_ metrics: ProcessListMetrics) { + let content = columnsRect + let isRightToLeft = effectiveUserInterfaceLayoutDirection == .rightToLeft + var icon = CGRect( + x: content.minX, + y: pixelAligned(contentView.bounds.midY - metrics.iconSize / 2), + width: metrics.iconSize, + height: metrics.iconSize + ) + var text = content + text.origin.x = icon.maxX + metrics.iconSpacing + text.size.width = max(0, content.maxX - text.minX) + if isRightToLeft { + icon.origin.x = content.minX + content.maxX - icon.maxX + text.origin.x = content.minX + content.maxX - text.maxX + } + iconView.frame = icon + + var y = contentView.bounds.minY + ProcessListMetrics.stackedPadding + let nameHeight = ceil(metrics.nameFont.lineHeight) + nameLabel.frame = CGRect(x: text.minX, y: y, width: text.width, height: nameHeight) + nameLabel.textAlignment = .natural + y += nameHeight + let valueHeight = ceil(metrics.valueFont.lineHeight) + for line in [[cpuLabel, memoryLabel], [pidLabel, threadsLabel]] { + y += ProcessListMetrics.lineSpacing + var offset: CGFloat = 0 + for label in line { + let width = min(ceil(label.intrinsicContentSize.width), max(0, text.width - offset)) + let x = isRightToLeft ? text.maxX - offset - width : text.minX + offset + label.frame = CGRect(x: x, y: y, width: width, height: valueHeight) + label.textAlignment = .natural + label.isHidden = false + offset += width + metrics.columnSpacing + } + y += valueHeight + } + } + + private func label(for column: ProcessColumn) -> UILabel { + switch column { + case .pid: pidLabel + case .cpu: cpuLabel + case .memory: memoryLabel + case .threads: threadsLabel + } + } + + private func centered(height: CGFloat, in rect: CGRect) -> CGRect { + CGRect(x: rect.minX, y: pixelAligned(rect.midY - height / 2), width: rect.width, height: height) + } + + private func pixelAligned(_ value: CGFloat) -> CGFloat { + let scale = max(traitCollection.displayScale, 1) + return (value * scale).rounded() / scale + } } diff --git a/Inspector/Resources/Localizable.xcstrings b/Inspector/Resources/Localizable.xcstrings index b64420e..a147ef1 100644 --- a/Inspector/Resources/Localizable.xcstrings +++ b/Inspector/Resources/Localizable.xcstrings @@ -6388,7 +6388,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Arbeitsspeicher" + "value": "Speicher" } }, "en": {