Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- The data grid column header now shows a column's comment on a second line under the name, not just in the hover tooltip. Turn it on with View > Show Object Comments. (#1789)

## [0.54.0] - 2026-06-30

### Added
Expand Down
41 changes: 41 additions & 0 deletions TablePro/Views/Results/DataGridColumnPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class DataGridColumnPool {
private weak var attachedTableView: NSTableView?

var totalSlots: Int { pooledColumns.count }
private(set) var requiresCommentLine = false

func attach(to tableView: NSTableView) {
attachedTableView = tableView
Expand Down Expand Up @@ -42,6 +43,14 @@ final class DataGridColumnPool {
let willRestoreWidths = !(savedLayout?.columnWidths.isEmpty ?? true)
let hiddenFromLayout = savedLayout?.hiddenColumns ?? []

requiresCommentLine = visibleColumnHasComment(
schema: schema,
visibleCount: visibleCount,
columnComments: columnComments,
hiddenFromLayout: hiddenFromLayout,
hiddenColumnNames: hiddenColumnNames
)

for slot in 0..<pooledColumns.count {
let column = pooledColumns[slot]
if slot < visibleCount {
Expand All @@ -54,6 +63,7 @@ final class DataGridColumnPool {
name: columnName,
columnType: slot < columnTypes.count ? columnTypes[slot] : nil,
comment: columnComments[columnName],
isTwoLine: requiresCommentLine,
width: resolvedWidth,
isEditable: isEditable
)
Expand All @@ -77,6 +87,26 @@ final class DataGridColumnPool {
visibleCount: visibleCount,
targetOrder: targetOrder
)

(tableView.headerView as? SortableHeaderView)?.showsCommentLine = requiresCommentLine
}

private func visibleColumnHasComment(
schema: ColumnIdentitySchema,
visibleCount: Int,
columnComments: [String: String],
hiddenFromLayout: Set<String>,
hiddenColumnNames: Set<String>
) -> Bool {
guard !columnComments.isEmpty else { return false }
for slot in 0..<visibleCount {
let columnName = schema.columnNames[slot]
guard !hiddenFromLayout.contains(columnName), !hiddenColumnNames.contains(columnName) else { continue }
if let comment = columnComments[columnName], !comment.isEmpty {
return true
}
}
return false
}

private func growBackingPoolIfNeeded(to count: Int) {
Expand Down Expand Up @@ -180,6 +210,7 @@ final class DataGridColumnPool {
name: String,
columnType: ColumnType?,
comment: String?,
isTwoLine: Bool,
width: CGFloat,
isEditable: Bool
) {
Expand All @@ -190,6 +221,16 @@ final class DataGridColumnPool {
column.headerCell = cell
}

if let headerCell = column.headerCell as? SortableHeaderCell {
let resolvedComment = comment?.isEmpty == false ? comment : nil
if headerCell.comment != resolvedComment {
headerCell.comment = resolvedComment
Comment on lines +226 to +227

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redraw headers when subtitle text changes

When object comments are already visible and at least one visible column still has a comment, a later metadata refresh can change headerCell.comment without changing requiresCommentLine; showsCommentLine therefore remains true and its didSet does not invalidate the header. Because this is just a custom stored property on the cell, AppKit will keep painting the old subtitle until some unrelated redraw such as a resize or hover occurs. Please mark the affected header rect, or the header view, as needing display when the comment value changes.

Useful? React with 👍 / 👎.

}
if headerCell.isTwoLineLayout != isTwoLine {
headerCell.isTwoLineLayout = isTwoLine
}
}

var tooltip: String
if let typeName = columnType?.rawType ?? columnType?.displayName {
tooltip = "\(name) (\(typeName))"
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Results/DataGridUpdateSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ struct DataGridUpdateSnapshot: Equatable {
let alternatingRows: Bool
let reloadVersion: Int
let showObjectComments: Bool
let columnCommentsSignature: Int
}
15 changes: 14 additions & 1 deletion TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ struct DataGridView: NSViewRepresentable {
headerMenu.delegate = context.coordinator
sortableHeader.menu = headerMenu
tableView.headerView = sortableHeader
sortableHeader.showsCommentLine = context.coordinator.columnPool.requiresCommentLine

let hasMoveRow = delegate != nil
if hasMoveRow {
Expand Down Expand Up @@ -167,7 +168,10 @@ struct DataGridView: NSViewRepresentable {
rowHeight: rowHeight,
alternatingRows: alternatingRows,
reloadVersion: changeManager.reloadVersion,
showObjectComments: AppSettingsManager.shared.general.showObjectComments
showObjectComments: AppSettingsManager.shared.general.showObjectComments,
columnCommentsSignature: AppSettingsManager.shared.general.showObjectComments
? latestRows.columnComments.hashValue
: 0
)

if snapshot != coordinator.lastUpdateSnapshot {
Expand Down Expand Up @@ -325,6 +329,15 @@ struct DataGridView: NSViewRepresentable {
)
}
)

let requiresCommentLine = coordinator.columnPool.requiresCommentLine
if let rowNumberColumn = tableView.tableColumns.first(where: {
$0.identifier == ColumnIdentitySchema.rowNumberIdentifier
}),
let rowNumberCell = rowNumberColumn.headerCell as? SortableHeaderCell,
rowNumberCell.isTwoLineLayout != requiresCommentLine {
rowNumberCell.isTwoLineLayout = requiresCommentLine
}
}

private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) {
Expand Down
72 changes: 66 additions & 6 deletions TablePro/Views/Results/SortableHeaderCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ final class SortableHeaderCell: NSTableHeaderCell {
var isValueFiltered: Bool = false
var isFunnelVisible: Bool = false
var supportsValueFilter: Bool = true
var comment: String?
var isTwoLineLayout: Bool = false

private static let indicatorPadding: CGFloat = 4
private static let indicatorSpacing: CGFloat = 2
private static let priorityFontSize: CGFloat = 9
private static let defaultIndicatorSize = NSSize(width: 9, height: 6)
private static let funnelSize = NSSize(width: 13, height: 13)
private static let funnelPointSize: CGFloat = 11
private static let subtitleFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize - 1)
static let commentBandHeight: CGFloat = ceil(SortableHeaderCell.subtitleFont.boundingRectForFont.height) + 4

override init(textCell string: String) {
super.init(textCell: string)
Expand All @@ -42,12 +46,18 @@ final class SortableHeaderCell: NSTableHeaderCell {
}

let foreground = foregroundColor(emphasized: isColumnSelected)
let nameBand = nameBandRect(forBounds: cellFrame)

drawTitle(
in: titleRect(forBounds: cellFrame),
in: titleRect(forBounds: nameBand),
font: titleFont(isSorted: sortDirection != nil),
color: foreground
)

if isTwoLineLayout, let comment, !comment.isEmpty {
drawSubtitle(comment, in: commentBandRect(forBounds: cellFrame))
}

var trailingCursorX = cellFrame.maxX - Self.indicatorPadding

if supportsValueFilter {
Expand All @@ -59,7 +69,7 @@ final class SortableHeaderCell: NSTableHeaderCell {
let drawSize = funnelImage?.size ?? Self.funnelSize
let funnelRect = NSRect(
x: trailingCursorX - drawSize.width,
y: cellFrame.midY - drawSize.height / 2,
y: nameBand.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
Expand All @@ -73,7 +83,7 @@ final class SortableHeaderCell: NSTableHeaderCell {
let indicatorImage = Self.indicatorImage(for: direction, color: foreground)
let indicatorSize = indicatorImage?.size ?? Self.defaultIndicatorSize
let indicatorOriginX = trailingCursorX - indicatorSize.width
let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2
let indicatorOriginY = nameBand.midY - indicatorSize.height / 2
let indicatorRect = NSRect(
x: indicatorOriginX,
y: indicatorOriginY,
Expand All @@ -87,20 +97,37 @@ final class SortableHeaderCell: NSTableHeaderCell {
let textOriginX = indicatorOriginX - Self.indicatorSpacing - priorityWidth
let textRect = NSRect(
x: textOriginX,
y: cellFrame.minY,
y: nameBand.minY,
width: priorityWidth,
height: cellFrame.height
height: nameBand.height
)
Self.drawPriorityText(priorityText, in: textRect, color: foreground)
}
}

func nameBandRect(forBounds rect: NSRect) -> NSRect {
guard isTwoLineLayout else { return rect }
let bandHeight = max(0, rect.height - Self.commentBandHeight)
return NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: bandHeight)
}

private func commentBandRect(forBounds rect: NSRect) -> NSRect {
let nameBand = nameBandRect(forBounds: rect)
return NSRect(
x: rect.minX,
y: nameBand.maxY,
width: rect.width,
height: max(0, rect.maxY - nameBand.maxY)
)
}

func funnelRect(forBounds rect: NSRect) -> NSRect {
guard supportsValueFilter else { return .null }
let band = nameBandRect(forBounds: rect)
let size = Self.funnelSize
return NSRect(
x: rect.maxX - Self.indicatorPadding - size.width,
y: rect.midY - size.height / 2,
y: band.midY - size.height / 2,
width: size.width,
height: size.height
)
Expand Down Expand Up @@ -178,6 +205,36 @@ final class SortableHeaderCell: NSTableHeaderCell {
title.draw(in: drawRect)
}

private func drawSubtitle(_ text: String, in rect: NSRect) {
let inset = min(DataGridMetrics.cellHorizontalInset, rect.width / 2)
let availableWidth = max(0, rect.width - inset * 2)
guard availableWidth > 0, rect.height > 0 else { return }

let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
paragraph.lineBreakMode = .byTruncatingTail

let attributes: [NSAttributedString.Key: Any] = [
.font: Self.subtitleFont,
.foregroundColor: subtitleColor(),
.paragraphStyle: paragraph
]

let subtitle = NSAttributedString(string: text, attributes: attributes)
let textHeight = subtitle.size().height
let drawRect = NSRect(
x: rect.minX + inset,
y: rect.midY - textHeight / 2,
width: availableWidth,
height: textHeight
)
subtitle.draw(in: drawRect)
}

private func subtitleColor() -> NSColor {
isColumnSelected ? .alternateSelectedControlTextColor.withAlphaComponent(0.8) : .secondaryLabelColor
}

override func drawSortIndicator(
withFrame cellFrame: NSRect,
in controlView: NSView,
Expand All @@ -187,6 +244,9 @@ final class SortableHeaderCell: NSTableHeaderCell {

override func accessibilityLabel() -> String? {
var components = [super.accessibilityLabel() ?? stringValue]
if isTwoLineLayout, let comment, !comment.isEmpty {
components.append(comment)
}
if let direction = sortDirection {
switch direction {
case .ascending:
Expand Down
20 changes: 20 additions & 0 deletions TablePro/Views/Results/SortableHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,33 @@ final class SortableHeaderView: NSTableHeaderView {
private var dragOccurredDuringClick = false
private var mouseMovedTrackingArea: NSTrackingArea?
private var hoveredColumnIndex: Int?
private var baseHeight: CGFloat = 0

var showsCommentLine: Bool = false {
didSet {
guard oldValue != showsCommentLine else { return }
tableView?.tile()
needsDisplay = true
}
}

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
baseHeight = frameRect.height
}

required init?(coder: NSCoder) {
super.init(coder: coder)
baseHeight = frame.height
}

override func setFrameSize(_ newSize: NSSize) {
if baseHeight <= 0 {
baseHeight = newSize.height
}
var size = newSize
size.height = showsCommentLine ? baseHeight + SortableHeaderCell.commentBandHeight : baseHeight
super.setFrameSize(size)
}

override func updateTrackingAreas() {
Expand Down
73 changes: 73 additions & 0 deletions TableProTests/Views/Results/DataGridColumnPoolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,77 @@ struct DataGridColumnPoolTests {
#expect(dataColumns(in: tableView).count == 3)
#expect(pool.totalSlots == 3)
}

@Test("reconcile sets comment subtitle state on header cells")
func reconcile_setsCommentStateOnHeaderCells() {
let pool = DataGridColumnPool()
let tableView = makeTableView()
let schema = ColumnIdentitySchema(columns: ["id", "name"])

pool.reconcile(
tableView: tableView,
schema: schema,
columnTypes: makeColumnTypes(count: 2),
columnComments: ["name": "Display name"],
savedLayout: nil,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

#expect(pool.requiresCommentLine)
let cellsByName = Dictionary(uniqueKeysWithValues: dataColumns(in: tableView).compactMap { column -> (String, SortableHeaderCell)? in
guard let cell = column.headerCell as? SortableHeaderCell else { return nil }
return (cell.stringValue, cell)
})
#expect(cellsByName["name"]?.comment == "Display name")
#expect(cellsByName["id"]?.comment == nil)
#expect(cellsByName["id"]?.isTwoLineLayout == true)
#expect(cellsByName["name"]?.isTwoLineLayout == true)
}

@Test("reconcile stays single line when no visible column has a comment")
func reconcile_singleLineWhenNoComments() {
let pool = DataGridColumnPool()
let tableView = makeTableView()
let schema = ColumnIdentitySchema(columns: ["id", "name"])

pool.reconcile(
tableView: tableView,
schema: schema,
columnTypes: makeColumnTypes(count: 2),
columnComments: [:],
savedLayout: nil,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

#expect(pool.requiresCommentLine == false)
let cells = dataColumns(in: tableView).compactMap { $0.headerCell as? SortableHeaderCell }
#expect(cells.allSatisfy { !$0.isTwoLineLayout })
}

@Test("reconcile ignores comments on hidden columns for the subtitle row")
func reconcile_ignoresCommentsOnHiddenColumns() {
let pool = DataGridColumnPool()
let tableView = makeTableView()
let schema = ColumnIdentitySchema(columns: ["id", "name"])

var layout = ColumnLayoutState()
layout.hiddenColumns = ["name"]

pool.reconcile(
tableView: tableView,
schema: schema,
columnTypes: makeColumnTypes(count: 2),
columnComments: ["name": "Display name"],
savedLayout: layout,
isEditable: true,
hiddenColumnNames: [],
widthCalculator: defaultWidthCalculator
)

#expect(pool.requiresCommentLine == false)
}
}
Loading
Loading