From 27e559f0292dedcf4efb2027aa4298e32a4c0a2e Mon Sep 17 00:00:00 2001 From: jintao <1639398556@qq.com> Date: Tue, 30 Jun 2026 17:31:37 +0800 Subject: [PATCH] feat(datagrid): show column type and comment as persistent header subtitle --- TablePro/Models/Query/TableRows.swift | 4 + .../Views/Results/DataGridColumnPool.swift | 17 +++++ .../Results/DataGridUpdateSnapshot.swift | 1 + TablePro/Views/Results/DataGridView.swift | 28 ++++++- .../Views/Results/SortableHeaderCell.swift | 74 ++++++++++++++++++- .../Views/Results/SortableHeaderView.swift | 18 +++++ 6 files changed, 138 insertions(+), 4 deletions(-) diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 899f4780e..39ceb3f9d 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -17,6 +17,7 @@ struct TableRows: Sendable { var columnNullable: [String: Bool] var columnComments: [String: String] var foreignKeysFetched: Bool + var displayMetadataVersion: Int = 0 init( rows: ContiguousArray = [], @@ -192,6 +193,9 @@ struct TableRows: Sendable { self.columnComments = columnComments didChange = true } + if didChange { + displayMetadataVersion &+= 1 + } return didChange ? .columnsReplaced : .none } diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index 7432d6e4a..42bc4a75a 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -190,6 +190,23 @@ final class DataGridColumnPool { column.headerCell = cell } + let resolvedComment = comment?.isEmpty == false ? comment : nil + let typeName = columnType?.displayName ?? columnType?.rawType + if let headerCell = column.headerCell as? SortableHeaderCell { + var changed = false + if headerCell.comment != resolvedComment { + headerCell.comment = resolvedComment + changed = true + } + if headerCell.typeDisplayName != typeName { + headerCell.typeDisplayName = typeName + changed = true + } + if changed { + column.headerCell = headerCell + } + } + var tooltip: String if let typeName = columnType?.rawType ?? columnType?.displayName { tooltip = "\(name) (\(typeName))" diff --git a/TablePro/Views/Results/DataGridUpdateSnapshot.swift b/TablePro/Views/Results/DataGridUpdateSnapshot.swift index 133ffd02d..c160096b9 100644 --- a/TablePro/Views/Results/DataGridUpdateSnapshot.swift +++ b/TablePro/Views/Results/DataGridUpdateSnapshot.swift @@ -21,4 +21,5 @@ struct DataGridUpdateSnapshot: Equatable { let alternatingRows: Bool let reloadVersion: Int let showObjectComments: Bool + let displayMetadataVersion: Int } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index e29267c74..f687c31b0 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -167,7 +167,8 @@ struct DataGridView: NSViewRepresentable { rowHeight: rowHeight, alternatingRows: alternatingRows, reloadVersion: changeManager.reloadVersion, - showObjectComments: AppSettingsManager.shared.general.showObjectComments + showObjectComments: AppSettingsManager.shared.general.showObjectComments, + displayMetadataVersion: latestRows.displayMetadataVersion ) if snapshot != coordinator.lastUpdateSnapshot { @@ -306,13 +307,15 @@ struct DataGridView: NSViewRepresentable { tableRows: TableRows, savedLayout: ColumnLayoutState? ) { - let columnComments = AppSettingsManager.shared.general.showObjectComments + let showComments = AppSettingsManager.shared.general.showObjectComments + let columnComments = showComments ? tableRows.columnComments : [:] + let columnTypes = showComments ? tableRows.columnTypes : [] coordinator.columnPool.reconcile( tableView: tableView, schema: coordinator.identitySchema, - columnTypes: tableRows.columnTypes, + columnTypes: columnTypes, columnComments: columnComments, savedLayout: savedLayout, isEditable: isEditable, @@ -325,6 +328,25 @@ struct DataGridView: NSViewRepresentable { ) } ) + + if let headerView = tableView.headerView as? SortableHeaderView { + var maxLineCount = 0 + if showComments { + for (index, colName) in tableRows.columns.enumerated() { + let hasType = index < columnTypes.count && columnTypes[index].displayName != nil + let hasComment = !(columnComments[colName]?.isEmpty ?? true) + let lineCount: Int + switch (hasType, hasComment) { + case (true, true): lineCount = 2 + case (true, false), (false, true): lineCount = 1 + default: lineCount = 0 + } + maxLineCount = max(maxLineCount, lineCount) + } + } + headerView.applyHeaderHeight(subtitleLineCount: maxLineCount) + headerView.needsDisplay = true + } } private func syncSortDescriptors(tableView: NSTableView, coordinator: TableViewCoordinator, columns: [String]) { diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 6998850c0..87d13773a 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -13,6 +13,18 @@ final class SortableHeaderCell: NSTableHeaderCell { var isValueFiltered: Bool = false var isFunnelVisible: Bool = false var supportsValueFilter: Bool = true + var comment: String? + var typeDisplayName: String? + + var subtitleLineCount: Int { + let hasType = typeDisplayName != nil + let hasComment = !(comment?.isEmpty ?? true) + switch (hasType, hasComment) { + case (true, true): return 2 + case (true, false), (false, true): return 1 + case (false, false): return 0 + } + } private static let indicatorPadding: CGFloat = 4 private static let indicatorSpacing: CGFloat = 2 @@ -20,6 +32,7 @@ final class SortableHeaderCell: NSTableHeaderCell { 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 fixedTitleHeight: CGFloat = 15 override init(textCell string: String) { super.init(textCell: string) @@ -42,8 +55,20 @@ final class SortableHeaderCell: NSTableHeaderCell { } let foreground = foregroundColor(emphasized: isColumnSelected) + let lineCount = subtitleLineCount + let hasType = typeDisplayName != nil + let hasComment = !(comment?.isEmpty ?? true) + + let titleFrame: NSRect + if lineCount > 0 { + let titleHeight = min(cellFrame.height * 0.55, Self.fixedTitleHeight) + titleFrame = NSRect(x: cellFrame.minX, y: cellFrame.minY, width: cellFrame.width, height: titleHeight) + } else { + titleFrame = cellFrame + } + drawTitle( - in: titleRect(forBounds: cellFrame), + in: titleRect(forBounds: titleFrame), font: titleFont(isSorted: sortDirection != nil), color: foreground ) @@ -68,6 +93,22 @@ final class SortableHeaderCell: NSTableHeaderCell { trailingCursorX -= Self.funnelSize.width + Self.indicatorSpacing } + if lineCount > 0 { + let subtitleStartY = titleFrame.maxY + let subtitleTotalHeight = cellFrame.maxY - subtitleStartY + let lineHeight = subtitleTotalHeight / CGFloat(lineCount) + var lineY = subtitleStartY + if hasType, let type = typeDisplayName { + let typeFrame = NSRect(x: cellFrame.minX, y: lineY, width: cellFrame.width, height: lineHeight) + drawSubtitle(type, in: typeFrame, color: foreground) + lineY += lineHeight + } + if hasComment, let commentText = comment { + let commentFrame = NSRect(x: cellFrame.minX, y: lineY, width: cellFrame.width, height: lineHeight) + drawSubtitle(commentText, in: commentFrame, color: foreground) + } + } + guard let direction = sortDirection else { return } let indicatorImage = Self.indicatorImage(for: direction, color: foreground) @@ -178,6 +219,37 @@ final class SortableHeaderCell: NSTableHeaderCell { title.draw(in: drawRect) } + private func drawSubtitle(_ text: String, in rect: NSRect, color: NSColor) { + let inset = DataGridMetrics.cellHorizontalInset + let drawRect = NSRect( + x: rect.minX + inset, + y: rect.minY + 1, + width: max(0, rect.width - inset * 2), + height: max(0, rect.height - 2) + ) + guard drawRect.width > 0, drawRect.height > 0 else { return } + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = alignment + paragraph.lineBreakMode = .byTruncatingTail + + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: NSFont.labelFontSize), + .foregroundColor: color.withAlphaComponent(0.65), + .paragraphStyle: paragraph + ] + + let attrString = NSAttributedString(string: text, attributes: attributes) + let textHeight = attrString.size().height + let centeredRect = NSRect( + x: drawRect.minX, + y: drawRect.minY + (drawRect.height - textHeight) / 2, + width: drawRect.width, + height: textHeight + ) + attrString.draw(in: centeredRect) + } + override func drawSortIndicator( withFrame cellFrame: NSRect, in controlView: NSView, diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 43c879b26..0f25c6c67 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -65,6 +65,9 @@ final class SortableHeaderView: NSTableHeaderView { private static let clickDragThreshold: CGFloat = 4 private static let resizeZoneWidth: CGFloat = 4 + static let defaultHeaderHeight: CGFloat = 17 + static let singleSubtitleHeaderHeight: CGFloat = 28 + static let doubleSubtitleHeaderHeight: CGFloat = 38 private var pendingClickStartLocation: NSPoint? private var dragOccurredDuringClick = false @@ -194,6 +197,21 @@ final class SortableHeaderView: NSTableHeaderView { } } + func applyHeaderHeight(subtitleLineCount: Int) { + let targetHeight: CGFloat + switch subtitleLineCount { + case 2: targetHeight = Self.doubleSubtitleHeaderHeight + case 1: targetHeight = Self.singleSubtitleHeaderHeight + default: targetHeight = Self.defaultHeaderHeight + } + guard frame.height != targetHeight else { return } + frame.size.height = targetHeight + if let tableView { + tableView.tile() + needsDisplay = true + } + } + override func mouseDragged(with event: NSEvent) { if let start = pendingClickStartLocation { let current = convert(event.locationInWindow, from: nil)