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": {