From e0cec6f1e0d8a7f6c80059480c9785a203078148 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 24 Oct 2026 11:07:13 +0700 Subject: [PATCH 1/3] feat: Add file type icon view with status badges to native lists Introduce FileTypeIconView rendering Material file-type icons with corner status badges for native file lists. Apply it across file status and commit file list/tree views and tighten row heights for the denser layout. --- macgit/Views/Common/FileTypeIconView.swift | 75 +++++++++++++++++++ .../FileStatus/FileStatusNativeList.swift | 20 ++--- .../FileStatus/FileStatusTreeNativeList.swift | 20 ++--- .../Views/History/CommitFileNativeList.swift | 17 ++--- .../History/CommitFileTreeNativeList.swift | 12 +-- 5 files changed, 107 insertions(+), 37 deletions(-) create mode 100644 macgit/Views/Common/FileTypeIconView.swift diff --git a/macgit/Views/Common/FileTypeIconView.swift b/macgit/Views/Common/FileTypeIconView.swift new file mode 100644 index 00000000..65aa50aa --- /dev/null +++ b/macgit/Views/Common/FileTypeIconView.swift @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit + +/// AppKit counterpart of `RevisionTreeIcon` for native file lists: a Material +/// file-type icon with an optional status badge in the bottom-trailing corner. +final class FileTypeIconView: NSView { + private let fileIcon = NSImageView() + private let badge = NSImageView() + private var path = "" + private var isDirectory = false + private var isExpanded = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + fileIcon.imageScaling = .scaleProportionallyUpOrDown + badge.imageScaling = .scaleProportionallyUpOrDown + badge.wantsLayer = true + addSubview(fileIcon) + addSubview(badge) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(path: String, isDirectory: Bool, isExpanded: Bool = false, + statusSymbol: String? = nil, statusColor: NSColor? = nil, statusLabel: String? = nil) { + self.path = path + self.isDirectory = isDirectory + self.isExpanded = isExpanded + updateFileIcon() + if let statusSymbol { + badge.image = NSImage(systemSymbolName: statusSymbol, accessibilityDescription: statusLabel)? + .withSymbolConfiguration(.init(pointSize: 8, weight: .bold)) + badge.contentTintColor = statusColor + badge.isHidden = false + } else { + badge.isHidden = true + } + setAccessibilityLabel(statusLabel) + updateBadgeBackground() + needsLayout = true + } + + override func layout() { + super.layout() + fileIcon.frame = bounds + let size = round(bounds.width * 0.6) + badge.frame = NSRect(x: bounds.maxX - size + 2, y: bounds.minY - 2, width: size, height: size) + badge.layer?.cornerRadius = size / 2 + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateFileIcon() + updateBadgeBackground() + } + + private func updateFileIcon() { + let isLight = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua + if let icons = RevisionMaterialIcons.shared { + fileIcon.image = NSImage(named: "revision-material-" + icons.icon( + for: path, isDirectory: isDirectory, isExpanded: isExpanded, isLight: isLight)) + fileIcon.contentTintColor = nil + } else { + fileIcon.image = NSImage(systemSymbolName: isDirectory ? "folder" : "doc.text", accessibilityDescription: nil) + fileIcon.contentTintColor = .secondaryLabelColor + } + } + + private func updateBadgeBackground() { + // A backing disc keeps the badge legible over the file icon artwork. + effectiveAppearance.performAsCurrentDrawingAppearance { + badge.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor + } + } +} diff --git a/macgit/Views/FileStatus/FileStatusNativeList.swift b/macgit/Views/FileStatus/FileStatusNativeList.swift index 4770af34..d1140fcd 100644 --- a/macgit/Views/FileStatus/FileStatusNativeList.swift +++ b/macgit/Views/FileStatus/FileStatusNativeList.swift @@ -95,7 +95,7 @@ struct FileStatusNativeList: NSViewRepresentable { let scaleChanged = appliedScale != model.textScale if scaleChanged { appliedScale = model.textScale - tableView.rowHeight = 48 * model.textScale + tableView.rowHeight = 40 * model.textScale } let oldRows = rows @@ -316,7 +316,7 @@ private final class FileStatusNativeRowView: NSTableRowView { private final class FileStatusNativeCell: NSTableCellView { let moreButton = FileStatusPointingButton() private let checkbox = FileStatusPointingButton(checkboxWithTitle: "", target: nil, action: nil) - private let icon = NSImageView() + private let icon = FileTypeIconView() private let nameLabel = NSTextField(labelWithString: "") private let pathLabel = NSTextField(labelWithString: "") private let addedCountLabel = NSTextField(labelWithString: "") @@ -342,7 +342,6 @@ private final class FileStatusNativeCell: NSTableCellView { checkbox.controlSize = .small checkbox.target = self checkbox.action = #selector(toggleSelection) - icon.imageScaling = .scaleProportionallyDown nameLabel.lineBreakMode = .byTruncatingMiddle pathLabel.lineBreakMode = .byTruncatingMiddle pathLabel.textColor = .tertiaryLabelColor @@ -389,11 +388,8 @@ private final class FileStatusNativeCell: NSTableCellView { pathLabel.stringValue = row.file.originalPath.map { "\($0) → \(row.file.path)" } ?? row.file.directory pathLabel.font = .systemFont(ofSize: 10 * textScale) pathLabel.textColor = row.file.originalPath == nil ? .tertiaryLabelColor : .secondaryLabelColor - icon.image = NSImage( - systemSymbolName: Self.iconName(for: row.file), - accessibilityDescription: row.file.status.rawValue.capitalized - ) - icon.contentTintColor = Self.color(for: row.file) + icon.configure(path: row.file.path, isDirectory: false, statusSymbol: Self.iconName(for: row.file), + statusColor: Self.color(for: row.file), statusLabel: row.file.status.rawValue.capitalized) if let count = row.count { let font = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium) addedCountLabel.font = font @@ -433,7 +429,7 @@ private final class FileStatusNativeCell: NSTableCellView { super.layout() let midY = bounds.midY checkbox.frame = NSRect(x: 5, y: midY - 9, width: 18, height: 18) - icon.frame = NSRect(x: 29, y: midY - 9, width: 18, height: 18) + icon.frame = NSRect(x: 27, y: midY - 8, width: 16, height: 16) moreButton.frame = NSRect(x: bounds.maxX - 24, y: midY - 11, width: 22, height: 22) quickButton.frame = NSRect(x: moreButton.frame.minX - 22, y: midY - 11, width: 22, height: 22) var accessoryX = quickButton.frame.minX @@ -445,9 +441,9 @@ private final class FileStatusNativeCell: NSTableCellView { accessoryX -= 34 lfsLabel.frame = NSRect(x: accessoryX, y: midY - 9, width: 30, height: 18) } - let textX: CGFloat = 53 - let textWidth = max(20, accessoryX - textX - 6) - nameLabel.frame = NSRect(x: textX, y: midY + 1, width: textWidth, height: 18) + let textX: CGFloat = 48 + let textWidth = max(20, accessoryX - textX - 4) + nameLabel.frame = NSRect(x: textX, y: midY, width: textWidth, height: 18) let countWidth = addedCountWidth + removedCountWidth + (addedCountWidth > 0 ? 4 : 0) pathLabel.frame = NSRect(x: textX, y: midY - 17, width: max(20, textWidth - countWidth - 6), height: 16) addedCountLabel.frame = NSRect( diff --git a/macgit/Views/FileStatus/FileStatusTreeNativeList.swift b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift index f1ecf24c..28d15de0 100644 --- a/macgit/Views/FileStatus/FileStatusTreeNativeList.swift +++ b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift @@ -48,7 +48,7 @@ struct FileStatusTreeNativeList: NSViewRepresentable { scroll.hasVerticalScroller = true; scroll.autohidesScrollers = true; scroll.drawsBackground = false let column = NSTableColumn(identifier: .init("status-tree")); column.resizingMask = .autoresizingMask table.addTableColumn(column); table.headerView = nil; table.backgroundColor = .clear - table.rowHeight = 36; table.intercellSpacing = .zero; table.selectionHighlightStyle = .none + table.style = .plain; table.rowHeight = 28; table.intercellSpacing = .zero; table.selectionHighlightStyle = .none table.dataSource = self; table.delegate = self; table.target = self; table.action = #selector(clickedRow) table.doubleAction = #selector(doubleClickedRow) table.contextMenuProvider = { [weak self] row in self?.contextMenu(row) } @@ -203,7 +203,7 @@ private final class StatusTreeTableView: NSTableView { private final class StatusTreeCell: NSTableCellView { let moreButton = NSButton() private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) - private let chevron = NSImageView(), icon = NSImageView() + private let chevron = NSImageView(), icon = FileTypeIconView() private let name = NSTextField(labelWithString: ""), added = NSTextField(labelWithString: ""), removed = NSTextField(labelWithString: "") private let lfs = NSTextField(labelWithString: "LFS"), conflict = NSButton(), quick = NSButton() private var onToggle: ((Bool) -> Void)?, onQuick: (() -> Void)?, onMore: (() -> Void)?, onConflict: (() -> Void)? @@ -213,7 +213,7 @@ private final class StatusTreeCell: NSTableCellView { super.init(frame: frame) [checkbox, chevron, icon, name, added, removed, lfs, conflict, quick, moreButton].forEach(addSubview) checkbox.setButtonType(.switch); checkbox.isBordered = false; checkbox.controlSize = .small; checkbox.target = self; checkbox.action = #selector(toggle) - chevron.imageScaling = .scaleProportionallyDown; icon.imageScaling = .scaleProportionallyDown + chevron.imageScaling = .scaleProportionallyDown name.lineBreakMode = .byTruncatingMiddle; added.textColor = .systemGreen; removed.textColor = .systemRed lfs.textColor = .secondaryLabelColor; lfs.alignment = .center; lfs.wantsLayer = true; lfs.layer?.borderWidth = 1; lfs.layer?.cornerRadius = 3 [conflict, quick, moreButton].forEach { $0.isBordered = false } @@ -229,7 +229,7 @@ private final class StatusTreeCell: NSTableCellView { self.depth = depth; self.onToggle = onToggleSelection; self.onQuick = onQuick checkbox.state = checkState; checkbox.allowsMixedState = true chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil) - icon.image = NSImage(systemSymbolName: expanded ? "folder.fill.badge.minus" : "folder.fill", accessibilityDescription: "Folder"); icon.contentTintColor = .systemBlue + icon.configure(path: title, isDirectory: true, isExpanded: expanded) name.stringValue = title; name.font = .systemFont(ofSize: 13 * textScale, weight: .medium) quick.image = NSImage(systemSymbolName: isStaged ? "minus" : "plus", accessibilityDescription: isStaged ? "Unstage folder" : "Stage folder") chevron.isHidden = false; added.isHidden = true; removed.isHidden = true; lfs.isHidden = true; conflict.isHidden = true; moreButton.isHidden = true; quick.isHidden = false @@ -241,7 +241,7 @@ private final class StatusTreeCell: NSTableCellView { onMore: @escaping () -> Void, onConflict: @escaping () -> Void) { self.depth = depth; self.onToggle = onToggleSelection; self.onQuick = onQuick; self.onMore = onMore; self.onConflict = onConflict checkbox.state = row.isActionSelected ? .on : .off; checkbox.allowsMixedState = false; chevron.isHidden = true - icon.image = NSImage(systemSymbolName: Self.symbol(row.file.status), accessibilityDescription: nil); icon.contentTintColor = Self.color(row.file.status) + icon.configure(path: row.file.path, isDirectory: false, statusSymbol: Self.symbol(row.file.status), statusColor: Self.color(row.file.status), statusLabel: row.file.status.rawValue.capitalized) name.stringValue = title; name.font = .systemFont(ofSize: 13 * textScale, weight: .medium) if let count = row.count { added.stringValue = "+\(count.added)"; removed.stringValue = "−\(count.removed)"; added.isHidden = false; removed.isHidden = false } else { added.isHidden = true; removed.isHidden = true } let countFont = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium); added.font = countFont; removed.font = countFont @@ -252,18 +252,18 @@ private final class StatusTreeCell: NSTableCellView { } override func layout() { - super.layout(); let y = bounds.midY; let x = CGFloat(depth) * 14 + 6 + super.layout(); let y = bounds.midY; let x = CGFloat(depth) * 12 + 6 checkbox.frame = .init(x: x, y: y - 9, width: 18, height: 18) - chevron.frame = .init(x: x + 22, y: y - 6, width: 10, height: 12) - icon.frame = .init(x: x + (chevron.isHidden ? 22 : 36), y: y - 9, width: 18, height: 18) - var right = bounds.maxX - 6 + chevron.frame = .init(x: x + 20, y: y - 6, width: 10, height: 12) + icon.frame = .init(x: x + (chevron.isHidden ? 20 : 33), y: y - 8, width: 16, height: 16) + var right = bounds.maxX - 4 moreButton.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); if !moreButton.isHidden { right -= 24 } quick.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 24 conflict.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); if !conflict.isHidden { right -= 24 } if !lfs.isHidden { lfs.frame = .init(x: right - 30, y: y - 9, width: 28, height: 18); right -= 34 } if !removed.isHidden { let w = ceil(removed.intrinsicContentSize.width) + 4; removed.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 4 } if !added.isHidden { let w = ceil(added.intrinsicContentSize.width) + 4; added.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 } - name.frame = .init(x: icon.frame.maxX + 7, y: y - 9, width: max(20, right - icon.frame.maxX - 9), height: 18) + name.frame = .init(x: icon.frame.maxX + 5, y: y - 9, width: max(20, right - icon.frame.maxX - 7), height: 18) } @objc private func toggle() { onToggle?(checkbox.state == .on) } @objc private func runQuick() { onQuick?() } diff --git a/macgit/Views/History/CommitFileNativeList.swift b/macgit/Views/History/CommitFileNativeList.swift index b304b6a1..f8c9b311 100644 --- a/macgit/Views/History/CommitFileNativeList.swift +++ b/macgit/Views/History/CommitFileNativeList.swift @@ -110,7 +110,7 @@ struct CommitFileNativeList: NSViewRepresentable { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { guard rows.indices.contains(row), let model else { return 48 } - return (rows[row].change.oldPath == nil ? 48 : 62) * model.textScale + return (rows[row].change.oldPath == nil ? 40 : 62) * model.textScale } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { @@ -194,7 +194,7 @@ private final class CommitFileTableView: NSTableView { } private final class CommitFileNativeCell: NSTableCellView { - private let icon = NSImageView() + private let icon = FileTypeIconView() private let nameLabel = NSTextField(labelWithString: "") private let pathLabel = NSTextField(labelWithString: "") private let oldPathLabel = NSTextField(labelWithString: "") @@ -211,7 +211,6 @@ private final class CommitFileNativeCell: NSTableCellView { for view in [icon, nameLabel, pathLabel, oldPathLabel, addedLabel, removedLabel, statusLabel, openButton] { addSubview(view) } - icon.imageScaling = .scaleProportionallyDown nameLabel.lineBreakMode = .byTruncatingMiddle pathLabel.lineBreakMode = .byTruncatingMiddle pathLabel.textColor = .tertiaryLabelColor @@ -245,8 +244,8 @@ private final class CommitFileNativeCell: NSTableCellView { oldPathLabel.font = .systemFont(ofSize: 9 * textScale) oldPathLabel.isHidden = change.oldPath == nil - icon.image = NSImage(systemSymbolName: Self.symbol(for: change.status), accessibilityDescription: change.status.displayText) - icon.contentTintColor = Self.color(for: change.status) + icon.configure(path: change.path, isDirectory: false, statusSymbol: Self.symbol(for: change.status), + statusColor: Self.color(for: change.status), statusLabel: change.status.displayText) let countFont = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium) if let count = row.count { @@ -280,7 +279,7 @@ private final class CommitFileNativeCell: NSTableCellView { override func layout() { super.layout() let midY = bounds.midY - icon.frame = NSRect(x: 6, y: midY - 9, width: 18, height: 18) + icon.frame = NSRect(x: 4, y: midY - 8, width: 16, height: 16) let trailingWidth: CGFloat if !openButton.isHidden { openButton.frame = NSRect(x: bounds.maxX - 28, y: midY - 11, width: 24, height: 22) @@ -288,12 +287,12 @@ private final class CommitFileNativeCell: NSTableCellView { } else { let width = min(70, statusLabel.intrinsicContentSize.width + 12) statusLabel.frame = NSRect(x: bounds.maxX - width - 4, y: midY - 10, width: width, height: 20) - trailingWidth = width + 6 + trailingWidth = width + 4 } - let textX: CGFloat = 30 + let textX: CGFloat = 25 let textRight = bounds.maxX - trailingWidth let countWidth = addedWidth + removedWidth + (addedWidth > 0 ? 4 : 0) - nameLabel.frame = NSRect(x: textX, y: midY + 2, width: max(20, textRight - textX), height: 18) + nameLabel.frame = NSRect(x: textX, y: midY + 1, width: max(20, textRight - textX), height: 18) pathLabel.frame = NSRect(x: textX, y: midY - 16, width: max(20, textRight - textX - countWidth - 6), height: 16) addedLabel.frame = NSRect(x: pathLabel.frame.maxX + 6, y: midY - 16, width: addedWidth, height: 16) diff --git a/macgit/Views/History/CommitFileTreeNativeList.swift b/macgit/Views/History/CommitFileTreeNativeList.swift index baba947d..99a33363 100644 --- a/macgit/Views/History/CommitFileTreeNativeList.swift +++ b/macgit/Views/History/CommitFileTreeNativeList.swift @@ -29,7 +29,7 @@ struct CommitFileTreeNativeList: NSViewRepresentable { func makeScrollView() -> NSScrollView { let scroll = NSScrollView(); scroll.hasVerticalScroller = true; scroll.autohidesScrollers = true; scroll.drawsBackground = false let column = NSTableColumn(identifier: .init("commit-tree")); column.resizingMask = .autoresizingMask - table.addTableColumn(column); table.headerView = nil; table.backgroundColor = .clear; table.rowHeight = 36; table.intercellSpacing = .zero; table.selectionHighlightStyle = .none + table.addTableColumn(column); table.headerView = nil; table.backgroundColor = .clear; table.style = .plain; table.rowHeight = 28; table.intercellSpacing = .zero; table.selectionHighlightStyle = .none table.dataSource = self; table.delegate = self; table.target = self; table.action = #selector(clicked); table.doubleAction = #selector(doubleClicked) table.contextMenuProvider = { [weak self] in self?.contextMenu($0) }; scroll.documentView = table; return scroll } @@ -72,13 +72,13 @@ struct CommitFileTreeNativeList: NSViewRepresentable { private final class CommitTreeTableView: NSTableView { var contextMenuProvider: ((Int) -> NSMenu?)?; override func menu(for event: NSEvent) -> NSMenu? { let i = row(at: convert(event.locationInWindow, from: nil)); return i < 0 ? nil : contextMenuProvider?(i) } } private final class CommitTreeCell: NSTableCellView { - private let chevron = NSImageView(), icon = NSImageView(), name = NSTextField(labelWithString: ""), added = NSTextField(labelWithString: ""), removed = NSTextField(labelWithString: ""), status = NSTextField(labelWithString: ""), open = NSButton() + private let chevron = NSImageView(), icon = FileTypeIconView(), name = NSTextField(labelWithString: ""), added = NSTextField(labelWithString: ""), removed = NSTextField(labelWithString: ""), status = NSTextField(labelWithString: ""), open = NSButton() private var depth = 0; private var onOpen: (() -> Void)? - override init(frame: NSRect) { super.init(frame: frame); [chevron, icon, name, added, removed, status, open].forEach(addSubview); chevron.imageScaling = .scaleProportionallyDown; icon.imageScaling = .scaleProportionallyDown; name.lineBreakMode = .byTruncatingMiddle; added.textColor = .systemGreen; removed.textColor = .systemRed; status.alignment = .center; open.isBordered = false; open.image = NSImage(systemSymbolName: "arrow.up.forward.app", accessibilityDescription: "Open with application"); open.target = self; open.action = #selector(openFile) } + override init(frame: NSRect) { super.init(frame: frame); [chevron, icon, name, added, removed, status, open].forEach(addSubview); chevron.imageScaling = .scaleProportionallyDown; name.lineBreakMode = .byTruncatingMiddle; added.textColor = .systemGreen; removed.textColor = .systemRed; status.alignment = .center; open.isBordered = false; open.image = NSImage(systemSymbolName: "arrow.up.forward.app", accessibilityDescription: "Open with application"); open.target = self; open.action = #selector(openFile) } required init?(coder: NSCoder) { fatalError() } - func configureFolder(name title: String, depth: Int, expanded: Bool, textScale: CGFloat) { self.depth = depth; chevron.isHidden = false; chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil); icon.image = NSImage(systemSymbolName: expanded ? "folder.fill.badge.minus" : "folder.fill", accessibilityDescription: "Folder"); icon.contentTintColor = .systemBlue; name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); added.isHidden = true; removed.isHidden = true; status.isHidden = true; open.isHidden = true; wantsLayer = true; layer?.backgroundColor = NSColor.clear.cgColor; needsLayout = true } - func configureFile(change: CommitFileChange, name title: String, count: FileLineChangeCount?, depth: Int, selected: Bool, textScale: CGFloat, showsOpen: Bool, onOpen: @escaping () -> Void) { self.depth = depth; self.onOpen = onOpen; chevron.isHidden = true; icon.image = NSImage(systemSymbolName: Self.symbol(change.status), accessibilityDescription: nil); icon.contentTintColor = Self.color(change.status); name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); if let count { added.stringValue = "+\(count.added)"; removed.stringValue = "−\(count.removed)"; added.isHidden = false; removed.isHidden = false } else { added.isHidden = true; removed.isHidden = true }; let font = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium); added.font = font; removed.font = font; status.stringValue = change.status.rawValue; status.font = font; status.textColor = Self.color(change.status); status.isHidden = showsOpen; open.isHidden = !showsOpen; wantsLayer = true; layer?.backgroundColor = selected ? NSColor.controlAccentColor.withAlphaComponent(0.16).cgColor : NSColor.clear.cgColor; needsLayout = true } - override func layout() { super.layout(); let y = bounds.midY, x = CGFloat(depth) * 14 + 8; chevron.frame = .init(x: x, y: y - 6, width: 10, height: 12); icon.frame = .init(x: x + (chevron.isHidden ? 0 : 16), y: y - 9, width: 18, height: 18); var right = bounds.maxX - 8; if !open.isHidden { open.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 26 } else if !status.isHidden { let w = min(68, status.intrinsicContentSize.width + 8); status.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; if !removed.isHidden { let w = ceil(removed.intrinsicContentSize.width) + 4; removed.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 4 }; if !added.isHidden { let w = ceil(added.intrinsicContentSize.width) + 4; added.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; name.frame = .init(x: icon.frame.maxX + 7, y: y - 9, width: max(20, right - icon.frame.maxX - 9), height: 18) } + func configureFolder(name title: String, depth: Int, expanded: Bool, textScale: CGFloat) { self.depth = depth; chevron.isHidden = false; chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil); icon.configure(path: title, isDirectory: true, isExpanded: expanded); name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); added.isHidden = true; removed.isHidden = true; status.isHidden = true; open.isHidden = true; wantsLayer = true; layer?.backgroundColor = NSColor.clear.cgColor; needsLayout = true } + func configureFile(change: CommitFileChange, name title: String, count: FileLineChangeCount?, depth: Int, selected: Bool, textScale: CGFloat, showsOpen: Bool, onOpen: @escaping () -> Void) { self.depth = depth; self.onOpen = onOpen; chevron.isHidden = true; icon.configure(path: change.path, isDirectory: false, statusSymbol: Self.symbol(change.status), statusColor: Self.color(change.status), statusLabel: change.status.displayText); name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); if let count { added.stringValue = "+\(count.added)"; removed.stringValue = "−\(count.removed)"; added.isHidden = false; removed.isHidden = false } else { added.isHidden = true; removed.isHidden = true }; let font = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium); added.font = font; removed.font = font; status.stringValue = change.status.rawValue; status.font = font; status.textColor = Self.color(change.status); status.isHidden = showsOpen; open.isHidden = !showsOpen; wantsLayer = true; layer?.backgroundColor = selected ? NSColor.controlAccentColor.withAlphaComponent(0.16).cgColor : NSColor.clear.cgColor; needsLayout = true } + override func layout() { super.layout(); let y = bounds.midY, x = CGFloat(depth) * 12 + 4; chevron.frame = .init(x: x, y: y - 6, width: 10, height: 12); icon.frame = .init(x: x + (chevron.isHidden ? 0 : 13), y: y - 8, width: 16, height: 16); var right = bounds.maxX - 4; if !open.isHidden { open.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 26 } else if !status.isHidden { let w = min(68, status.intrinsicContentSize.width + 8); status.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; if !removed.isHidden { let w = ceil(removed.intrinsicContentSize.width) + 4; removed.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 4 }; if !added.isHidden { let w = ceil(added.intrinsicContentSize.width) + 4; added.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; name.frame = .init(x: icon.frame.maxX + 5, y: y - 9, width: max(20, right - icon.frame.maxX - 7), height: 18) } @objc private func openFile() { onOpen?() } private static func symbol(_ s: CommitFileStatus) -> String { switch s { case .added: "plus.circle.fill"; case .modified: "pencil.circle.fill"; case .deleted: "minus.circle.fill"; case .renamed: "arrow.right.circle.fill"; case .copied: "doc.on.doc.fill" } } private static func color(_ s: CommitFileStatus) -> NSColor { switch s { case .added: .systemGreen; case .modified: .systemOrange; case .deleted: .systemRed; case .renamed: .systemBlue; case .copied: .systemPurple } } From 010855b04994dc0a80fbf45360e2f3d3fb99d95b Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 24 Oct 2026 11:16:10 +0700 Subject: [PATCH 2/3] refactor: Extract shared file change row content view Replace duplicated icon, label, and count layout in native file lists with FileChangeRowContentView and show status icons beside file type icons. --- .../Common/FileChangeRowContentView.swift | 154 ++++++++++++++++++ macgit/Views/Common/FileTypeIconView.swift | 47 +++--- .../FileStatus/FileStatusNativeList.swift | 96 +---------- .../FileStatus/FileStatusTreeNativeList.swift | 24 +-- .../Views/History/CommitFileNativeList.swift | 112 ++----------- .../History/CommitFileTreeNativeList.swift | 12 +- 6 files changed, 211 insertions(+), 234 deletions(-) create mode 100644 macgit/Views/Common/FileChangeRowContentView.swift diff --git a/macgit/Views/Common/FileChangeRowContentView.swift b/macgit/Views/Common/FileChangeRowContentView.swift new file mode 100644 index 00000000..3b9737a2 --- /dev/null +++ b/macgit/Views/Common/FileChangeRowContentView.swift @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit + +/// Symbol and color used for a file's change type in native file lists. +struct FileChangeStatusStyle { + let symbol: String + let color: NSColor + let label: String +} + +extension FileStatus { + var changeStyle: FileChangeStatusStyle { + switch self { + case .added: .init(symbol: "plus.circle.fill", color: .systemGreen, label: "Added") + case .modified: .init(symbol: "pencil.circle.fill", color: .systemOrange, label: "Modified") + case .staged: .init(symbol: "pencil.circle.fill", color: .systemOrange, label: "Staged") + case .deleted: .init(symbol: "minus.circle.fill", color: .systemRed, label: "Deleted") + case .renamed: .init(symbol: "arrow.right.circle.fill", color: .systemBlue, label: "Renamed") + case .untracked: .init(symbol: "questionmark.circle.fill", color: .systemGray, label: "Untracked") + case .conflict: .init(symbol: "exclamationmark.triangle.fill", color: .systemPurple, label: "Conflict") + } + } +} + +extension CommitFileStatus { + var changeStyle: FileChangeStatusStyle { + switch self { + case .added: .init(symbol: "plus.circle.fill", color: .systemGreen, label: displayText) + case .modified: .init(symbol: "pencil.circle.fill", color: .systemOrange, label: displayText) + case .deleted: .init(symbol: "minus.circle.fill", color: .systemRed, label: displayText) + case .renamed: .init(symbol: "arrow.right.circle.fill", color: .systemBlue, label: displayText) + case .copied: .init(symbol: "doc.on.doc.fill", color: .systemPurple, label: displayText) + } + } +} + +/// Shared content of a file row in the File Status, commit detail and pull +/// request file lists: icons, name, optional path lines, and line counts. +/// Cells own their leading controls and trailing accessories and give this +/// view the space between them. +final class FileChangeRowContentView: NSView { + struct Content { + var path: String + var name: String + var isDirectory = false + var isExpanded = false + var status: FileChangeStatusStyle? + /// Second line under the name; nil keeps the row on a single line. + var subtitle: String? + var subtitleEmphasized = false + /// Optional third line, such as the source path of a rename. + var detail: String? + var count: FileLineChangeCount? + var nameFontSize: CGFloat = 12 + var textScale: CGFloat = 1 + } + + private static let iconSize: CGFloat = 16 + private let icon = FileTypeIconView() + private let nameLabel = NSTextField(labelWithString: "") + private let subtitleLabel = NSTextField(labelWithString: "") + private let detailLabel = NSTextField(labelWithString: "") + private let addedLabel = NSTextField(labelWithString: "") + private let removedLabel = NSTextField(labelWithString: "") + private var addedWidth: CGFloat = 0 + private var removedWidth: CGFloat = 0 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + for view in [icon, nameLabel, subtitleLabel, detailLabel, addedLabel, removedLabel] { + addSubview(view) + } + for label in [nameLabel, subtitleLabel, detailLabel] { + label.lineBreakMode = .byTruncatingMiddle + } + for label in [addedLabel, removedLabel] { + label.lineBreakMode = .byClipping + label.maximumNumberOfLines = 1 + } + detailLabel.textColor = .secondaryLabelColor + addedLabel.textColor = .systemGreen + removedLabel.textColor = .systemRed + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(_ content: Content) { + let scale = content.textScale + icon.configure(path: content.path, isDirectory: content.isDirectory, isExpanded: content.isExpanded, + statusSymbol: content.status?.symbol, statusColor: content.status?.color, + statusLabel: content.status?.label) + nameLabel.stringValue = content.name + nameLabel.font = .systemFont(ofSize: content.nameFontSize * scale, weight: .medium) + subtitleLabel.isHidden = content.subtitle == nil + subtitleLabel.stringValue = content.subtitle ?? "" + subtitleLabel.font = .systemFont(ofSize: 10 * scale) + subtitleLabel.textColor = content.subtitleEmphasized ? .secondaryLabelColor : .tertiaryLabelColor + detailLabel.isHidden = content.detail == nil + detailLabel.stringValue = content.detail ?? "" + detailLabel.font = .systemFont(ofSize: 9 * scale) + if let count = content.count { + let font = NSFont.monospacedSystemFont(ofSize: 10 * scale, weight: .medium) + addedLabel.font = font + removedLabel.font = font + addedLabel.stringValue = "+\(count.added)" + removedLabel.stringValue = "−\(count.removed)" + addedLabel.setAccessibilityLabel("\(count.added) lines added") + removedLabel.setAccessibilityLabel("\(count.removed) lines removed") + // AppKit's intrinsic width can end on the final glyph's antialiased edge. + // Keep a small trailing allowance so the last digit is never clipped. + addedWidth = ceil(addedLabel.intrinsicContentSize.width) + 3 + removedWidth = ceil(removedLabel.intrinsicContentSize.width) + 3 + addedLabel.isHidden = false + removedLabel.isHidden = false + } else { + addedWidth = 0 + removedWidth = 0 + addedLabel.isHidden = true + removedLabel.isHidden = true + } + // Counts arrive asynchronously and change the manually laid out widths. + needsLayout = true + } + + override func layout() { + super.layout() + let midY = bounds.midY + let size = Self.iconSize + icon.frame = NSRect(x: 0, y: midY - size / 2, width: icon.width(iconSize: size), height: size) + let textX = icon.frame.maxX + 5 + let textWidth = max(20, bounds.maxX - textX) + let countWidth = addedWidth + removedWidth + (addedWidth > 0 ? 4 : 0) + let countGap: CGFloat = countWidth > 0 ? 6 : 0 + + // Stack the visible text lines and center the block vertically. + let nameHeight: CGFloat = 18, subtitleHeight: CGFloat = 16, detailHeight: CGFloat = 14 + let blockHeight = nameHeight + (subtitleLabel.isHidden ? 0 : subtitleHeight) + + (detailLabel.isHidden ? 0 : detailHeight) + let nameY = floor(midY + blockHeight / 2 - nameHeight) + let countsY: CGFloat + if subtitleLabel.isHidden { + nameLabel.frame = NSRect(x: textX, y: nameY, width: max(20, textWidth - countWidth - countGap), height: nameHeight) + countsY = nameY + 1 + } else { + nameLabel.frame = NSRect(x: textX, y: nameY, width: textWidth, height: nameHeight) + countsY = nameY - subtitleHeight + subtitleLabel.frame = NSRect(x: textX, y: countsY, + width: max(20, textWidth - countWidth - countGap), height: subtitleHeight) + } + detailLabel.frame = NSRect(x: textX, y: countsY - detailHeight, width: textWidth, height: detailHeight) + removedLabel.frame = NSRect(x: bounds.maxX - removedWidth, y: countsY, width: removedWidth, height: 16) + addedLabel.frame = NSRect(x: removedLabel.frame.minX - 4 - addedWidth, y: countsY, width: addedWidth, height: 16) + } +} diff --git a/macgit/Views/Common/FileTypeIconView.swift b/macgit/Views/Common/FileTypeIconView.swift index 65aa50aa..bc004a13 100644 --- a/macgit/Views/Common/FileTypeIconView.swift +++ b/macgit/Views/Common/FileTypeIconView.swift @@ -1,26 +1,32 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import AppKit -/// AppKit counterpart of `RevisionTreeIcon` for native file lists: a Material -/// file-type icon with an optional status badge in the bottom-trailing corner. +/// AppKit counterpart of `RevisionTreeIcon` for native file lists: an optional +/// status icon followed by the Material file-type icon on the same row. final class FileTypeIconView: NSView { + static let spacing: CGFloat = 3 + + private let statusIcon = NSImageView() private let fileIcon = NSImageView() - private let badge = NSImageView() private var path = "" private var isDirectory = false private var isExpanded = false override init(frame frameRect: NSRect) { super.init(frame: frameRect) + statusIcon.imageScaling = .scaleProportionallyDown fileIcon.imageScaling = .scaleProportionallyUpOrDown - badge.imageScaling = .scaleProportionallyUpOrDown - badge.wantsLayer = true + addSubview(statusIcon) addSubview(fileIcon) - addSubview(badge) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + /// Width needed to show both icons at `iconSize`. + func width(iconSize: CGFloat) -> CGFloat { + statusIcon.isHidden ? iconSize : iconSize * 2 + Self.spacing + } + func configure(path: String, isDirectory: Bool, isExpanded: Bool = false, statusSymbol: String? = nil, statusColor: NSColor? = nil, statusLabel: String? = nil) { self.path = path @@ -28,30 +34,30 @@ final class FileTypeIconView: NSView { self.isExpanded = isExpanded updateFileIcon() if let statusSymbol { - badge.image = NSImage(systemSymbolName: statusSymbol, accessibilityDescription: statusLabel)? - .withSymbolConfiguration(.init(pointSize: 8, weight: .bold)) - badge.contentTintColor = statusColor - badge.isHidden = false + statusIcon.image = NSImage(systemSymbolName: statusSymbol, accessibilityDescription: statusLabel) + statusIcon.contentTintColor = statusColor + statusIcon.isHidden = false } else { - badge.isHidden = true + statusIcon.isHidden = true } setAccessibilityLabel(statusLabel) - updateBadgeBackground() needsLayout = true } override func layout() { super.layout() - fileIcon.frame = bounds - let size = round(bounds.width * 0.6) - badge.frame = NSRect(x: bounds.maxX - size + 2, y: bounds.minY - 2, width: size, height: size) - badge.layer?.cornerRadius = size / 2 + let size = bounds.height + if statusIcon.isHidden { + fileIcon.frame = NSRect(x: 0, y: 0, width: size, height: size) + } else { + statusIcon.frame = NSRect(x: 0, y: 0, width: size, height: size) + fileIcon.frame = NSRect(x: size + Self.spacing, y: 0, width: size, height: size) + } } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() updateFileIcon() - updateBadgeBackground() } private func updateFileIcon() { @@ -65,11 +71,4 @@ final class FileTypeIconView: NSView { fileIcon.contentTintColor = .secondaryLabelColor } } - - private func updateBadgeBackground() { - // A backing disc keeps the badge legible over the file icon artwork. - effectiveAppearance.performAsCurrentDrawingAppearance { - badge.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor - } - } } diff --git a/macgit/Views/FileStatus/FileStatusNativeList.swift b/macgit/Views/FileStatus/FileStatusNativeList.swift index d1140fcd..4a9861c1 100644 --- a/macgit/Views/FileStatus/FileStatusNativeList.swift +++ b/macgit/Views/FileStatus/FileStatusNativeList.swift @@ -316,16 +316,10 @@ private final class FileStatusNativeRowView: NSTableRowView { private final class FileStatusNativeCell: NSTableCellView { let moreButton = FileStatusPointingButton() private let checkbox = FileStatusPointingButton(checkboxWithTitle: "", target: nil, action: nil) - private let icon = FileTypeIconView() - private let nameLabel = NSTextField(labelWithString: "") - private let pathLabel = NSTextField(labelWithString: "") - private let addedCountLabel = NSTextField(labelWithString: "") - private let removedCountLabel = NSTextField(labelWithString: "") + private let content = FileChangeRowContentView() private let lfsLabel = NSTextField(labelWithString: "LFS") private let conflictButton = FileStatusPointingButton() private let quickButton = FileStatusPointingButton() - private var addedCountWidth: CGFloat = 0 - private var removedCountWidth: CGFloat = 0 private var onToggle: ((Bool) -> Void)? private var onQuick: (() -> Void)? private var onMore: (() -> Void)? @@ -333,8 +327,7 @@ private final class FileStatusNativeCell: NSTableCellView { override init(frame frameRect: NSRect) { super.init(frame: frameRect) - for view in [checkbox, icon, nameLabel, pathLabel, addedCountLabel, removedCountLabel, - lfsLabel, conflictButton, quickButton, moreButton] { + for view in [checkbox, content, lfsLabel, conflictButton, quickButton, moreButton] { addSubview(view) } checkbox.setButtonType(.switch) @@ -342,15 +335,6 @@ private final class FileStatusNativeCell: NSTableCellView { checkbox.controlSize = .small checkbox.target = self checkbox.action = #selector(toggleSelection) - nameLabel.lineBreakMode = .byTruncatingMiddle - pathLabel.lineBreakMode = .byTruncatingMiddle - pathLabel.textColor = .tertiaryLabelColor - for label in [addedCountLabel, removedCountLabel] { - label.lineBreakMode = .byClipping - label.maximumNumberOfLines = 1 - } - addedCountLabel.textColor = .systemGreen - removedCountLabel.textColor = .systemRed lfsLabel.alignment = .center lfsLabel.textColor = .secondaryLabelColor lfsLabel.wantsLayer = true @@ -383,33 +367,12 @@ private final class FileStatusNativeCell: NSTableCellView { self.onPotentialConflict = onPotentialConflict checkbox.state = row.isActionSelected ? .on : .off checkbox.setAccessibilityLabel("Select \(row.file.displayName)") - nameLabel.stringValue = row.file.displayName - nameLabel.font = .systemFont(ofSize: 13 * textScale, weight: .medium) - pathLabel.stringValue = row.file.originalPath.map { "\($0) → \(row.file.path)" } ?? row.file.directory - pathLabel.font = .systemFont(ofSize: 10 * textScale) - pathLabel.textColor = row.file.originalPath == nil ? .tertiaryLabelColor : .secondaryLabelColor - icon.configure(path: row.file.path, isDirectory: false, statusSymbol: Self.iconName(for: row.file), - statusColor: Self.color(for: row.file), statusLabel: row.file.status.rawValue.capitalized) - if let count = row.count { - let font = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium) - addedCountLabel.font = font - removedCountLabel.font = font - addedCountLabel.stringValue = "+\(count.added)" - removedCountLabel.stringValue = "-\(count.removed)" - // AppKit's intrinsic width can end on the final glyph's antialiased edge. - // Keep a small trailing allowance so the last digit is never clipped. - addedCountWidth = ceil(addedCountLabel.intrinsicContentSize.width) + 3 - removedCountWidth = ceil(removedCountLabel.intrinsicContentSize.width) + 3 - addedCountLabel.setAccessibilityLabel("\(count.added) lines added") - removedCountLabel.setAccessibilityLabel("\(count.removed) lines removed") - addedCountLabel.isHidden = false - removedCountLabel.isHidden = false - } else { - addedCountWidth = 0 - removedCountWidth = 0 - addedCountLabel.isHidden = true - removedCountLabel.isHidden = true - } + content.configure(.init( + path: row.file.path, name: row.file.displayName, status: row.file.status.changeStyle, + subtitle: row.file.originalPath.map { "\($0) → \(row.file.path)" } ?? row.file.directory, + subtitleEmphasized: row.file.originalPath != nil, + count: row.count, nameFontSize: 13, textScale: textScale + )) lfsLabel.isHidden = !row.isLFS lfsLabel.layer?.borderColor = NSColor.separatorColor.cgColor conflictButton.isHidden = !row.isPotentialConflict @@ -421,7 +384,6 @@ private final class FileStatusNativeCell: NSTableCellView { ) quickButton.toolTip = isStaged ? "Unstage" : "Stage" quickButton.setAccessibilityLabel(isStaged ? "Unstage" : "Stage") - // Counts arrive asynchronously and change the manually laid out widths. needsLayout = true } @@ -429,7 +391,6 @@ private final class FileStatusNativeCell: NSTableCellView { super.layout() let midY = bounds.midY checkbox.frame = NSRect(x: 5, y: midY - 9, width: 18, height: 18) - icon.frame = NSRect(x: 27, y: midY - 8, width: 16, height: 16) moreButton.frame = NSRect(x: bounds.maxX - 24, y: midY - 11, width: 22, height: 22) quickButton.frame = NSRect(x: moreButton.frame.minX - 22, y: midY - 11, width: 22, height: 22) var accessoryX = quickButton.frame.minX @@ -441,52 +402,13 @@ private final class FileStatusNativeCell: NSTableCellView { accessoryX -= 34 lfsLabel.frame = NSRect(x: accessoryX, y: midY - 9, width: 30, height: 18) } - let textX: CGFloat = 48 - let textWidth = max(20, accessoryX - textX - 4) - nameLabel.frame = NSRect(x: textX, y: midY, width: textWidth, height: 18) - let countWidth = addedCountWidth + removedCountWidth + (addedCountWidth > 0 ? 4 : 0) - pathLabel.frame = NSRect(x: textX, y: midY - 17, width: max(20, textWidth - countWidth - 6), height: 16) - addedCountLabel.frame = NSRect( - x: pathLabel.frame.maxX + 6, - y: midY - 17, - width: addedCountWidth, - height: 16 - ) - removedCountLabel.frame = NSRect( - x: addedCountLabel.frame.maxX + 4, - y: midY - 17, - width: removedCountWidth, - height: 16 - ) + content.frame = NSRect(x: 27, y: 0, width: max(0, accessoryX - 4 - 27), height: bounds.height) } @objc private func toggleSelection() { onToggle?(checkbox.state == .on) } @objc private func runQuickAction() { onQuick?() } @objc private func openMoreMenu() { onMore?() } @objc private func openPotentialConflict() { onPotentialConflict?() } - - private static func iconName(for file: StatusFile) -> String { - switch file.status { - case .added: "plus.circle.fill" - case .modified: "pencil.circle.fill" - case .deleted: "minus.circle.fill" - case .renamed: "arrow.right.circle.fill" - case .untracked: "questionmark.circle.fill" - case .conflict: "exclamationmark.triangle.fill" - case .staged: "pencil.circle.fill" - } - } - - private static func color(for file: StatusFile) -> NSColor { - switch file.status { - case .added: .systemGreen - case .staged, .modified: .systemOrange - case .deleted: .systemRed - case .renamed: .systemBlue - case .untracked: .systemGray - case .conflict: .systemPurple - } - } } private final class FileStatusPointingButton: NSButton { diff --git a/macgit/Views/FileStatus/FileStatusTreeNativeList.swift b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift index 28d15de0..cfeedf22 100644 --- a/macgit/Views/FileStatus/FileStatusTreeNativeList.swift +++ b/macgit/Views/FileStatus/FileStatusTreeNativeList.swift @@ -203,18 +203,16 @@ private final class StatusTreeTableView: NSTableView { private final class StatusTreeCell: NSTableCellView { let moreButton = NSButton() private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) - private let chevron = NSImageView(), icon = FileTypeIconView() - private let name = NSTextField(labelWithString: ""), added = NSTextField(labelWithString: ""), removed = NSTextField(labelWithString: "") + private let chevron = NSImageView(), content = FileChangeRowContentView() private let lfs = NSTextField(labelWithString: "LFS"), conflict = NSButton(), quick = NSButton() private var onToggle: ((Bool) -> Void)?, onQuick: (() -> Void)?, onMore: (() -> Void)?, onConflict: (() -> Void)? private var depth = 0 override init(frame: NSRect) { super.init(frame: frame) - [checkbox, chevron, icon, name, added, removed, lfs, conflict, quick, moreButton].forEach(addSubview) + [checkbox, chevron, content, lfs, conflict, quick, moreButton].forEach(addSubview) checkbox.setButtonType(.switch); checkbox.isBordered = false; checkbox.controlSize = .small; checkbox.target = self; checkbox.action = #selector(toggle) chevron.imageScaling = .scaleProportionallyDown - name.lineBreakMode = .byTruncatingMiddle; added.textColor = .systemGreen; removed.textColor = .systemRed lfs.textColor = .secondaryLabelColor; lfs.alignment = .center; lfs.wantsLayer = true; lfs.layer?.borderWidth = 1; lfs.layer?.cornerRadius = 3 [conflict, quick, moreButton].forEach { $0.isBordered = false } conflict.image = NSImage(systemSymbolName: "exclamationmark.triangle", accessibilityDescription: "Potential conflict"); conflict.contentTintColor = .systemOrange @@ -229,10 +227,9 @@ private final class StatusTreeCell: NSTableCellView { self.depth = depth; self.onToggle = onToggleSelection; self.onQuick = onQuick checkbox.state = checkState; checkbox.allowsMixedState = true chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil) - icon.configure(path: title, isDirectory: true, isExpanded: expanded) - name.stringValue = title; name.font = .systemFont(ofSize: 13 * textScale, weight: .medium) + content.configure(.init(path: title, name: title, isDirectory: true, isExpanded: expanded, nameFontSize: 13, textScale: textScale)) quick.image = NSImage(systemSymbolName: isStaged ? "minus" : "plus", accessibilityDescription: isStaged ? "Unstage folder" : "Stage folder") - chevron.isHidden = false; added.isHidden = true; removed.isHidden = true; lfs.isHidden = true; conflict.isHidden = true; moreButton.isHidden = true; quick.isHidden = false + chevron.isHidden = false; lfs.isHidden = true; conflict.isHidden = true; moreButton.isHidden = true; quick.isHidden = false needsLayout = true } @@ -241,10 +238,7 @@ private final class StatusTreeCell: NSTableCellView { onMore: @escaping () -> Void, onConflict: @escaping () -> Void) { self.depth = depth; self.onToggle = onToggleSelection; self.onQuick = onQuick; self.onMore = onMore; self.onConflict = onConflict checkbox.state = row.isActionSelected ? .on : .off; checkbox.allowsMixedState = false; chevron.isHidden = true - icon.configure(path: row.file.path, isDirectory: false, statusSymbol: Self.symbol(row.file.status), statusColor: Self.color(row.file.status), statusLabel: row.file.status.rawValue.capitalized) - name.stringValue = title; name.font = .systemFont(ofSize: 13 * textScale, weight: .medium) - if let count = row.count { added.stringValue = "+\(count.added)"; removed.stringValue = "−\(count.removed)"; added.isHidden = false; removed.isHidden = false } else { added.isHidden = true; removed.isHidden = true } - let countFont = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium); added.font = countFont; removed.font = countFont + content.configure(.init(path: row.file.path, name: title, status: row.file.status.changeStyle, count: row.count, nameFontSize: 13, textScale: textScale)) lfs.isHidden = !row.isLFS; lfs.font = .systemFont(ofSize: 9 * textScale); conflict.isHidden = !row.isPotentialConflict; moreButton.isHidden = false quick.isHidden = false; quick.image = NSImage(systemSymbolName: isStaged ? "minus" : "plus", accessibilityDescription: isStaged ? "Unstage" : "Stage") layer?.backgroundColor = row.isActionSelected ? NSColor.controlAccentColor.withAlphaComponent(0.16).cgColor : NSColor.clear.cgColor @@ -255,20 +249,16 @@ private final class StatusTreeCell: NSTableCellView { super.layout(); let y = bounds.midY; let x = CGFloat(depth) * 12 + 6 checkbox.frame = .init(x: x, y: y - 9, width: 18, height: 18) chevron.frame = .init(x: x + 20, y: y - 6, width: 10, height: 12) - icon.frame = .init(x: x + (chevron.isHidden ? 20 : 33), y: y - 8, width: 16, height: 16) var right = bounds.maxX - 4 moreButton.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); if !moreButton.isHidden { right -= 24 } quick.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 24 conflict.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); if !conflict.isHidden { right -= 24 } if !lfs.isHidden { lfs.frame = .init(x: right - 30, y: y - 9, width: 28, height: 18); right -= 34 } - if !removed.isHidden { let w = ceil(removed.intrinsicContentSize.width) + 4; removed.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 4 } - if !added.isHidden { let w = ceil(added.intrinsicContentSize.width) + 4; added.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 } - name.frame = .init(x: icon.frame.maxX + 5, y: y - 9, width: max(20, right - icon.frame.maxX - 7), height: 18) + let contentX = x + (chevron.isHidden ? 20 : 33) + content.frame = .init(x: contentX, y: 0, width: max(0, right - 2 - contentX), height: bounds.height) } @objc private func toggle() { onToggle?(checkbox.state == .on) } @objc private func runQuick() { onQuick?() } @objc private func openMore() { onMore?() } @objc private func openConflict() { onConflict?() } - private static func symbol(_ status: FileStatus) -> String { switch status { case .added: "plus.circle.fill"; case .staged, .modified: "pencil.circle.fill"; case .deleted: "minus.circle.fill"; case .renamed: "arrow.right.circle.fill"; case .untracked: "questionmark.circle.fill"; case .conflict: "exclamationmark.triangle.fill" } } - private static func color(_ status: FileStatus) -> NSColor { switch status { case .added: .systemGreen; case .staged, .modified: .systemOrange; case .deleted: .systemRed; case .renamed: .systemBlue; case .untracked: .systemGray; case .conflict: .systemPurple } } } diff --git a/macgit/Views/History/CommitFileNativeList.swift b/macgit/Views/History/CommitFileNativeList.swift index f8c9b311..3422a34a 100644 --- a/macgit/Views/History/CommitFileNativeList.swift +++ b/macgit/Views/History/CommitFileNativeList.swift @@ -110,7 +110,7 @@ struct CommitFileNativeList: NSViewRepresentable { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { guard rows.indices.contains(row), let model else { return 48 } - return (rows[row].change.oldPath == nil ? 40 : 62) * model.textScale + return (rows[row].change.oldPath == nil ? 40 : 56) * model.textScale } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { @@ -194,33 +194,14 @@ private final class CommitFileTableView: NSTableView { } private final class CommitFileNativeCell: NSTableCellView { - private let icon = FileTypeIconView() - private let nameLabel = NSTextField(labelWithString: "") - private let pathLabel = NSTextField(labelWithString: "") - private let oldPathLabel = NSTextField(labelWithString: "") - private let addedLabel = NSTextField(labelWithString: "") - private let removedLabel = NSTextField(labelWithString: "") - private let statusLabel = NSTextField(labelWithString: "") + private let content = FileChangeRowContentView() private let openButton = CommitFilePointingButton() - private var addedWidth: CGFloat = 0 - private var removedWidth: CGFloat = 0 private var onOpen: (() -> Void)? override init(frame frameRect: NSRect) { super.init(frame: frameRect) - for view in [icon, nameLabel, pathLabel, oldPathLabel, addedLabel, removedLabel, statusLabel, openButton] { - addSubview(view) - } - nameLabel.lineBreakMode = .byTruncatingMiddle - pathLabel.lineBreakMode = .byTruncatingMiddle - pathLabel.textColor = .tertiaryLabelColor - oldPathLabel.lineBreakMode = .byTruncatingMiddle - oldPathLabel.textColor = .secondaryLabelColor - addedLabel.textColor = .systemGreen - removedLabel.textColor = .systemRed - statusLabel.alignment = .center - statusLabel.wantsLayer = true - statusLabel.layer?.cornerRadius = 4 + addSubview(content) + addSubview(openButton) openButton.isBordered = false openButton.image = NSImage(systemSymbolName: "arrow.up.forward.app", accessibilityDescription: "Open with application") openButton.target = self @@ -235,92 +216,25 @@ private final class CommitFileNativeCell: NSTableCellView { self.onOpen = onOpen let change = row.change let name = URL(fileURLWithPath: change.path).lastPathComponent - let directory = URL(fileURLWithPath: change.path).deletingLastPathComponent().path - nameLabel.stringValue = name - nameLabel.font = .systemFont(ofSize: 12 * textScale, weight: .medium) - pathLabel.stringValue = directory - pathLabel.font = .systemFont(ofSize: 10 * textScale) - oldPathLabel.stringValue = change.oldPath.map { "From: \($0)" } ?? "" - oldPathLabel.font = .systemFont(ofSize: 9 * textScale) - oldPathLabel.isHidden = change.oldPath == nil - - icon.configure(path: change.path, isDirectory: false, statusSymbol: Self.symbol(for: change.status), - statusColor: Self.color(for: change.status), statusLabel: change.status.displayText) - - let countFont = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium) - if let count = row.count { - addedLabel.font = countFont - removedLabel.font = countFont - addedLabel.stringValue = "+\(count.added)" - removedLabel.stringValue = "-\(count.removed)" - addedLabel.setAccessibilityLabel("\(count.added) lines added") - removedLabel.setAccessibilityLabel("\(count.removed) lines removed") - addedWidth = ceil(addedLabel.intrinsicContentSize.width) + 3 - removedWidth = ceil(removedLabel.intrinsicContentSize.width) + 3 - addedLabel.isHidden = false - removedLabel.isHidden = false - } else { - addedWidth = 0 - removedWidth = 0 - addedLabel.isHidden = true - removedLabel.isHidden = true - } - + content.configure(.init( + path: change.path, name: name, status: change.status.changeStyle, + subtitle: URL(fileURLWithPath: change.path).deletingLastPathComponent().path, + detail: change.oldPath.map { "From: \($0)" }, + count: row.count, textScale: textScale + )) openButton.isHidden = !showsOpenButton openButton.setAccessibilityLabel("Open \(name) with an application") - statusLabel.isHidden = showsOpenButton - statusLabel.stringValue = change.status.displayText - statusLabel.font = .systemFont(ofSize: 10 * textScale, weight: .medium) - statusLabel.textColor = .secondaryLabelColor - statusLabel.layer?.backgroundColor = Self.color(for: change.status).withAlphaComponent(0.12).cgColor needsLayout = true } override func layout() { super.layout() - let midY = bounds.midY - icon.frame = NSRect(x: 4, y: midY - 8, width: 16, height: 16) - let trailingWidth: CGFloat - if !openButton.isHidden { - openButton.frame = NSRect(x: bounds.maxX - 28, y: midY - 11, width: 24, height: 22) - trailingWidth = 30 - } else { - let width = min(70, statusLabel.intrinsicContentSize.width + 12) - statusLabel.frame = NSRect(x: bounds.maxX - width - 4, y: midY - 10, width: width, height: 20) - trailingWidth = width + 4 - } - let textX: CGFloat = 25 - let textRight = bounds.maxX - trailingWidth - let countWidth = addedWidth + removedWidth + (addedWidth > 0 ? 4 : 0) - nameLabel.frame = NSRect(x: textX, y: midY + 1, width: max(20, textRight - textX), height: 18) - pathLabel.frame = NSRect(x: textX, y: midY - 16, - width: max(20, textRight - textX - countWidth - 6), height: 16) - addedLabel.frame = NSRect(x: pathLabel.frame.maxX + 6, y: midY - 16, width: addedWidth, height: 16) - removedLabel.frame = NSRect(x: addedLabel.frame.maxX + 4, y: midY - 16, width: removedWidth, height: 16) - oldPathLabel.frame = NSRect(x: textX, y: midY - 31, width: max(20, textRight - textX), height: 14) + openButton.frame = NSRect(x: bounds.maxX - 28, y: bounds.midY - 11, width: 24, height: 22) + let trailingWidth: CGFloat = openButton.isHidden ? 4 : 30 + content.frame = NSRect(x: 4, y: 0, width: max(0, bounds.maxX - trailingWidth - 4), height: bounds.height) } @objc private func openFile() { onOpen?() } - - private static func symbol(for status: CommitFileStatus) -> String { - switch status { - case .added: "plus.circle.fill" - case .modified: "pencil.circle.fill" - case .deleted: "minus.circle.fill" - case .renamed: "arrow.right.circle.fill" - case .copied: "doc.on.doc.fill" - } - } - - private static func color(for status: CommitFileStatus) -> NSColor { - switch status { - case .added: .systemGreen - case .modified: .systemOrange - case .deleted: .systemRed - case .renamed: .systemBlue - case .copied: .systemPurple - } - } } private final class CommitFilePointingButton: NSButton { diff --git a/macgit/Views/History/CommitFileTreeNativeList.swift b/macgit/Views/History/CommitFileTreeNativeList.swift index 99a33363..ddb12287 100644 --- a/macgit/Views/History/CommitFileTreeNativeList.swift +++ b/macgit/Views/History/CommitFileTreeNativeList.swift @@ -72,14 +72,12 @@ struct CommitFileTreeNativeList: NSViewRepresentable { private final class CommitTreeTableView: NSTableView { var contextMenuProvider: ((Int) -> NSMenu?)?; override func menu(for event: NSEvent) -> NSMenu? { let i = row(at: convert(event.locationInWindow, from: nil)); return i < 0 ? nil : contextMenuProvider?(i) } } private final class CommitTreeCell: NSTableCellView { - private let chevron = NSImageView(), icon = FileTypeIconView(), name = NSTextField(labelWithString: ""), added = NSTextField(labelWithString: ""), removed = NSTextField(labelWithString: ""), status = NSTextField(labelWithString: ""), open = NSButton() + private let chevron = NSImageView(), content = FileChangeRowContentView(), open = NSButton() private var depth = 0; private var onOpen: (() -> Void)? - override init(frame: NSRect) { super.init(frame: frame); [chevron, icon, name, added, removed, status, open].forEach(addSubview); chevron.imageScaling = .scaleProportionallyDown; name.lineBreakMode = .byTruncatingMiddle; added.textColor = .systemGreen; removed.textColor = .systemRed; status.alignment = .center; open.isBordered = false; open.image = NSImage(systemSymbolName: "arrow.up.forward.app", accessibilityDescription: "Open with application"); open.target = self; open.action = #selector(openFile) } + override init(frame: NSRect) { super.init(frame: frame); [chevron, content, open].forEach(addSubview); chevron.imageScaling = .scaleProportionallyDown; open.isBordered = false; open.image = NSImage(systemSymbolName: "arrow.up.forward.app", accessibilityDescription: "Open with application"); open.target = self; open.action = #selector(openFile) } required init?(coder: NSCoder) { fatalError() } - func configureFolder(name title: String, depth: Int, expanded: Bool, textScale: CGFloat) { self.depth = depth; chevron.isHidden = false; chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil); icon.configure(path: title, isDirectory: true, isExpanded: expanded); name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); added.isHidden = true; removed.isHidden = true; status.isHidden = true; open.isHidden = true; wantsLayer = true; layer?.backgroundColor = NSColor.clear.cgColor; needsLayout = true } - func configureFile(change: CommitFileChange, name title: String, count: FileLineChangeCount?, depth: Int, selected: Bool, textScale: CGFloat, showsOpen: Bool, onOpen: @escaping () -> Void) { self.depth = depth; self.onOpen = onOpen; chevron.isHidden = true; icon.configure(path: change.path, isDirectory: false, statusSymbol: Self.symbol(change.status), statusColor: Self.color(change.status), statusLabel: change.status.displayText); name.stringValue = title; name.font = .systemFont(ofSize: 12 * textScale, weight: .medium); if let count { added.stringValue = "+\(count.added)"; removed.stringValue = "−\(count.removed)"; added.isHidden = false; removed.isHidden = false } else { added.isHidden = true; removed.isHidden = true }; let font = NSFont.monospacedSystemFont(ofSize: 10 * textScale, weight: .medium); added.font = font; removed.font = font; status.stringValue = change.status.rawValue; status.font = font; status.textColor = Self.color(change.status); status.isHidden = showsOpen; open.isHidden = !showsOpen; wantsLayer = true; layer?.backgroundColor = selected ? NSColor.controlAccentColor.withAlphaComponent(0.16).cgColor : NSColor.clear.cgColor; needsLayout = true } - override func layout() { super.layout(); let y = bounds.midY, x = CGFloat(depth) * 12 + 4; chevron.frame = .init(x: x, y: y - 6, width: 10, height: 12); icon.frame = .init(x: x + (chevron.isHidden ? 0 : 13), y: y - 8, width: 16, height: 16); var right = bounds.maxX - 4; if !open.isHidden { open.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 26 } else if !status.isHidden { let w = min(68, status.intrinsicContentSize.width + 8); status.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; if !removed.isHidden { let w = ceil(removed.intrinsicContentSize.width) + 4; removed.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 4 }; if !added.isHidden { let w = ceil(added.intrinsicContentSize.width) + 4; added.frame = .init(x: right - w, y: y - 8, width: w, height: 16); right -= w + 6 }; name.frame = .init(x: icon.frame.maxX + 5, y: y - 9, width: max(20, right - icon.frame.maxX - 7), height: 18) } + func configureFolder(name title: String, depth: Int, expanded: Bool, textScale: CGFloat) { self.depth = depth; chevron.isHidden = false; chevron.image = NSImage(systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil); content.configure(.init(path: title, name: title, isDirectory: true, isExpanded: expanded, textScale: textScale)); open.isHidden = true; wantsLayer = true; layer?.backgroundColor = NSColor.clear.cgColor; needsLayout = true } + func configureFile(change: CommitFileChange, name title: String, count: FileLineChangeCount?, depth: Int, selected: Bool, textScale: CGFloat, showsOpen: Bool, onOpen: @escaping () -> Void) { self.depth = depth; self.onOpen = onOpen; chevron.isHidden = true; content.configure(.init(path: change.path, name: title, status: change.status.changeStyle, count: count, textScale: textScale)); open.isHidden = !showsOpen; wantsLayer = true; layer?.backgroundColor = selected ? NSColor.controlAccentColor.withAlphaComponent(0.16).cgColor : NSColor.clear.cgColor; needsLayout = true } + override func layout() { super.layout(); let y = bounds.midY, x = CGFloat(depth) * 12 + 4; chevron.frame = .init(x: x, y: y - 6, width: 10, height: 12); var right = bounds.maxX - 4; if !open.isHidden { open.frame = .init(x: right - 22, y: y - 11, width: 22, height: 22); right -= 26 }; let contentX = x + (chevron.isHidden ? 0 : 13); content.frame = .init(x: contentX, y: 0, width: max(0, right - contentX), height: bounds.height) } @objc private func openFile() { onOpen?() } - private static func symbol(_ s: CommitFileStatus) -> String { switch s { case .added: "plus.circle.fill"; case .modified: "pencil.circle.fill"; case .deleted: "minus.circle.fill"; case .renamed: "arrow.right.circle.fill"; case .copied: "doc.on.doc.fill" } } - private static func color(_ s: CommitFileStatus) -> NSColor { switch s { case .added: .systemGreen; case .modified: .systemOrange; case .deleted: .systemRed; case .renamed: .systemBlue; case .copied: .systemPurple } } } From 448fbf6833ecb212ad5e51fd7614fc7e9799c229 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sat, 24 Oct 2026 11:36:12 +0700 Subject: [PATCH 3/3] fix: Scale file row label heights with text size - Derive name, path, detail, and line-count frame heights from the scaled fonts so larger app text sizes are not clipped - Keep the previous fixed heights as minimums so the default size is unchanged Co-Authored-By: Claude Opus 5.5 --- .../Views/Common/FileChangeRowContentView.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/macgit/Views/Common/FileChangeRowContentView.swift b/macgit/Views/Common/FileChangeRowContentView.swift index 3b9737a2..2ebbb6b1 100644 --- a/macgit/Views/Common/FileChangeRowContentView.swift +++ b/macgit/Views/Common/FileChangeRowContentView.swift @@ -132,15 +132,22 @@ final class FileChangeRowContentView: NSView { let countWidth = addedWidth + removedWidth + (addedWidth > 0 ? 4 : 0) let countGap: CGFloat = countWidth > 0 ? 6 : 0 - // Stack the visible text lines and center the block vertically. - let nameHeight: CGFloat = 18, subtitleHeight: CGFloat = 16, detailHeight: CGFloat = 14 + // Stack the visible text lines and center the block vertically. Line + // heights follow the scaled fonts so larger text sizes are not clipped. + func lineHeight(_ label: NSTextField, minimum: CGFloat) -> CGFloat { + max(minimum, ceil(label.intrinsicContentSize.height)) + } + let nameHeight = lineHeight(nameLabel, minimum: 18) + let subtitleHeight = lineHeight(subtitleLabel, minimum: 16) + let detailHeight = lineHeight(detailLabel, minimum: 14) + let countHeight = lineHeight(addedLabel, minimum: 16) let blockHeight = nameHeight + (subtitleLabel.isHidden ? 0 : subtitleHeight) + (detailLabel.isHidden ? 0 : detailHeight) let nameY = floor(midY + blockHeight / 2 - nameHeight) let countsY: CGFloat if subtitleLabel.isHidden { nameLabel.frame = NSRect(x: textX, y: nameY, width: max(20, textWidth - countWidth - countGap), height: nameHeight) - countsY = nameY + 1 + countsY = nameY + floor((nameHeight - countHeight) / 2) } else { nameLabel.frame = NSRect(x: textX, y: nameY, width: textWidth, height: nameHeight) countsY = nameY - subtitleHeight @@ -148,7 +155,7 @@ final class FileChangeRowContentView: NSView { width: max(20, textWidth - countWidth - countGap), height: subtitleHeight) } detailLabel.frame = NSRect(x: textX, y: countsY - detailHeight, width: textWidth, height: detailHeight) - removedLabel.frame = NSRect(x: bounds.maxX - removedWidth, y: countsY, width: removedWidth, height: 16) - addedLabel.frame = NSRect(x: removedLabel.frame.minX - 4 - addedWidth, y: countsY, width: addedWidth, height: 16) + removedLabel.frame = NSRect(x: bounds.maxX - removedWidth, y: countsY, width: removedWidth, height: countHeight) + addedLabel.frame = NSRect(x: removedLabel.frame.minX - 4 - addedWidth, y: countsY, width: addedWidth, height: countHeight) } }