Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions macgit/Views/Common/FileChangeRowContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// 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. 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 + floor((nameHeight - countHeight) / 2)
} 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: countHeight)
addedLabel.frame = NSRect(x: removedLabel.frame.minX - 4 - addedWidth, y: countsY, width: addedWidth, height: countHeight)
}
}
74 changes: 74 additions & 0 deletions macgit/Views/Common/FileTypeIconView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit

/// 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 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
addSubview(statusIcon)
addSubview(fileIcon)
}

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
self.isDirectory = isDirectory
self.isExpanded = isExpanded
updateFileIcon()
if let statusSymbol {
statusIcon.image = NSImage(systemSymbolName: statusSymbol, accessibilityDescription: statusLabel)
statusIcon.contentTintColor = statusColor
statusIcon.isHidden = false
} else {
statusIcon.isHidden = true
}
setAccessibilityLabel(statusLabel)
needsLayout = true
}

override func layout() {
super.layout()
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()
}

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
}
}
}
Loading