diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e57f709..50209c9df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Data grid column comments now appear directly in column headers when object comments are enabled, instead of only being available from the header tooltip. (#1789) + ### Fixed - MySQL and MariaDB connections that prompt for a password at connect time now ask for a fresh password after an auto-reconnect hits an authentication failure such as `1045` / `SQLSTATE 28000`, instead of looping forever in Connecting with the expired session password. diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index 7432d6e4a..088367d45 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -65,6 +65,7 @@ final class DataGridColumnPool { column.isHidden = true } } + updateHeaderHeight(in: tableView, showsComments: hasVisibleComments(visibleCount: visibleCount)) let targetOrder = computeTargetOrder( visibleCount: visibleCount, @@ -189,6 +190,9 @@ final class DataGridColumnPool { cell.alignment = column.headerCell.alignment column.headerCell = cell } + if let headerCell = column.headerCell as? SortableHeaderCell { + headerCell.headerComment = comment + } var tooltip: String if let typeName = columnType?.rawType ?? columnType?.displayName { @@ -218,4 +222,20 @@ final class DataGridColumnPool { column.sortDescriptorPrototype = NSSortDescriptor(key: name, ascending: true) } } + + private func hasVisibleComments(visibleCount: Int) -> Bool { + pooledColumns.enumerated().contains { slot, column in + guard slot < visibleCount, + !column.isHidden, + let cell = column.headerCell as? SortableHeaderCell, + let comment = cell.headerComment else { + return false + } + return !comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func updateHeaderHeight(in tableView: NSTableView, showsComments: Bool) { + (tableView.headerView as? SortableHeaderView)?.showsComments = showsComments + } } diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 6998850c0..8850d981b 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -13,10 +13,13 @@ final class SortableHeaderCell: NSTableHeaderCell { var isValueFiltered: Bool = false var isFunnelVisible: Bool = false var supportsValueFilter: Bool = true + var headerComment: String? private static let indicatorPadding: CGFloat = 4 private static let indicatorSpacing: CGFloat = 2 private static let priorityFontSize: CGFloat = 9 + private static let commentFontSize: CGFloat = 10 + private static let commentLineSpacing: CGFloat = 1 private static let defaultIndicatorSize = NSSize(width: 9, height: 6) private static let funnelSize = NSSize(width: 13, height: 13) private static let funnelPointSize: CGFloat = 11 @@ -45,7 +48,9 @@ final class SortableHeaderCell: NSTableHeaderCell { drawTitle( in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil), - color: foreground + color: foreground, + comment: visibleComment, + commentColor: commentColor(emphasized: isColumnSelected) ) var trailingCursorX = cellFrame.maxX - Self.indicatorPadding @@ -152,30 +157,75 @@ final class SortableHeaderCell: NSTableHeaderCell { return NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) } + private var visibleComment: String? { + guard let headerComment else { return nil } + let trimmed = headerComment.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private func foregroundColor(emphasized: Bool) -> NSColor { emphasized ? .alternateSelectedControlTextColor : .headerTextColor } - private func drawTitle(in rect: NSRect, font titleFont: NSFont, color: NSColor) { + private func commentColor(emphasized: Bool) -> NSColor { + emphasized ? .alternateSelectedControlTextColor : .secondaryLabelColor + } + + private func drawTitle( + in rect: NSRect, + font titleFont: NSFont, + color: NSColor, + comment: String?, + commentColor: NSColor + ) { let paragraph = NSMutableParagraphStyle() paragraph.alignment = alignment paragraph.lineBreakMode = .byTruncatingTail - let attributes: [NSAttributedString.Key: Any] = [ + let titleAttributes: [NSAttributedString.Key: Any] = [ .font: titleFont, .foregroundColor: color, .paragraphStyle: paragraph ] - let title = NSAttributedString(string: stringValue, attributes: attributes) + let title = NSAttributedString(string: stringValue, attributes: titleAttributes) let textHeight = title.size().height + guard let comment else { + let drawRect = NSRect( + x: rect.minX, + y: rect.midY - textHeight / 2, + width: rect.width, + height: textHeight + ) + title.draw(in: drawRect) + return + } + + let commentAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: Self.commentFontSize), + .foregroundColor: commentColor, + .paragraphStyle: paragraph + ] + let commentText = NSAttributedString(string: comment, attributes: commentAttributes) + let commentHeight = commentText.size().height + let stackHeight = textHeight + Self.commentLineSpacing + commentHeight + let stackMinY = rect.midY - stackHeight / 2 + let drawRect = NSRect( x: rect.minX, - y: rect.midY - textHeight / 2, + y: stackMinY, width: rect.width, height: textHeight ) title.draw(in: drawRect) + + let commentRect = NSRect( + x: rect.minX, + y: stackMinY + textHeight + Self.commentLineSpacing, + width: rect.width, + height: commentHeight + ) + commentText.draw(in: commentRect) } override func drawSortIndicator( @@ -201,6 +251,9 @@ final class SortableHeaderCell: NSTableHeaderCell { if isValueFiltered { components.append(String(localized: "Filtered")) } + if let visibleComment { + components.append(visibleComment) + } return components.joined(separator: ", ") } diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index e3b7aa571..0dc900f73 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -63,22 +63,58 @@ enum HeaderSortCycle { final class SortableHeaderView: NSTableHeaderView { weak var coordinator: TableViewCoordinator? + static let commentHeaderHeight: CGFloat = 40 private static let clickDragThreshold: CGFloat = 4 private static let resizeZoneWidth: CGFloat = 4 + private static let fallbackHeight: CGFloat = 28 private var pendingClickStartLocation: NSPoint? private var dragOccurredDuringClick = false private var mouseMovedTrackingArea: NSTrackingArea? private var hoveredColumnIndex: Int? + /// Header height when no visible column carries a comment. Captured from the + /// height AppKit gave the header at creation so the compact layout keeps the + /// platform default instead of a hardcoded value. + private let naturalHeight: CGFloat + + /// Grows the header to `commentHeaderHeight` so each cell can draw its comment + /// on a second line. Enforced through `setFrameSize` so `NSScrollView.tile()` + /// cannot shrink it back on scroll, live resize, or column drag. + var showsComments = false { + didSet { + guard showsComments != oldValue else { return } + applyHeaderHeight() + } + } + override init(frame frameRect: NSRect) { + naturalHeight = frameRect.height > 0 ? frameRect.height : Self.fallbackHeight super.init(frame: frameRect) } required init?(coder: NSCoder) { + naturalHeight = Self.fallbackHeight super.init(coder: coder) } + override func setFrameSize(_ newSize: NSSize) { + var size = newSize + if showsComments { + size.height = Self.commentHeaderHeight + } + super.setFrameSize(size) + } + + private func applyHeaderHeight() { + let targetHeight = showsComments ? Self.commentHeaderHeight : naturalHeight + if frame.height != targetHeight { + setFrameSize(NSSize(width: frame.width, height: targetHeight)) + } + enclosingScrollView?.tile() + needsDisplay = true + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let existing = mouseMovedTrackingArea { diff --git a/TableProTests/Views/Results/DataGridColumnPoolTests.swift b/TableProTests/Views/Results/DataGridColumnPoolTests.swift index 1aa062ec2..394d532cc 100644 --- a/TableProTests/Views/Results/DataGridColumnPoolTests.swift +++ b/TableProTests/Views/Results/DataGridColumnPoolTests.swift @@ -318,7 +318,7 @@ struct DataGridColumnPoolTests { savedLayout: layout, isEditable: true, hiddenColumnNames: [], - widthCalculator: { _, _ in 9999 } + widthCalculator: { _, _ in 9_999 } ) let widthsByName = Dictionary(uniqueKeysWithValues: dataColumns(in: tableView).map { ($0.headerCell.stringValue, $0.width) }) @@ -326,6 +326,80 @@ struct DataGridColumnPoolTests { #expect(widthsByName["name"] == 250) } + @Test("reconcile assigns column comments to sortable header cells") + func reconcile_assignsColumnCommentsToHeaderCells() throws { + let pool = DataGridColumnPool() + let tableView = makeTableView() + let schema = ColumnIdentitySchema(columns: ["id", "email"]) + + pool.reconcile( + tableView: tableView, + schema: schema, + columnTypes: makeColumnTypes(count: 2), + columnComments: ["email": "Primary contact address"], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + widthCalculator: defaultWidthCalculator + ) + + let emailColumn = try #require(dataColumns(in: tableView).first { $0.headerCell.stringValue == "email" }) + let headerCell = try #require(emailColumn.headerCell as? SortableHeaderCell) + #expect(headerCell.headerComment == "Primary contact address") + #expect(emailColumn.headerToolTip == "email (Text)\nPrimary contact address") + + pool.reconcile( + tableView: tableView, + schema: schema, + columnTypes: makeColumnTypes(count: 2), + columnComments: [:], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + widthCalculator: defaultWidthCalculator + ) + + #expect(headerCell.headerComment == nil) + #expect(emailColumn.headerToolTip == "email (Text)") + } + + @Test("reconcile expands and restores header height based on visible comments") + func reconcile_updatesHeaderHeightForVisibleComments() { + let pool = DataGridColumnPool() + let tableView = makeTableView() + let naturalHeight: CGFloat = 28 + tableView.headerView = SortableHeaderView( + frame: NSRect(x: 0, y: 0, width: 200, height: naturalHeight) + ) + let schema = ColumnIdentitySchema(columns: ["id", "email"]) + + pool.reconcile( + tableView: tableView, + schema: schema, + columnTypes: makeColumnTypes(count: 2), + columnComments: ["email": "Primary contact address"], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + widthCalculator: defaultWidthCalculator + ) + + #expect(tableView.headerView?.frame.height == SortableHeaderView.commentHeaderHeight) + + pool.reconcile( + tableView: tableView, + schema: schema, + columnTypes: makeColumnTypes(count: 2), + columnComments: ["email": "Primary contact address"], + savedLayout: nil, + isEditable: true, + hiddenColumnNames: ["email"], + widthCalculator: defaultWidthCalculator + ) + + #expect(tableView.headerView?.frame.height == naturalHeight) + } + @Test("reconcile is idempotent for equivalent inputs") func reconcile_isIdempotentForEquivalentInputs() { let pool = DataGridColumnPool() @@ -384,7 +458,7 @@ struct DataGridColumnPoolTests { #expect(dataColumns(in: tableView).count == 3) pool.detachFromTableView() - #expect(dataColumns(in: tableView).count == 0) + #expect(dataColumns(in: tableView).isEmpty) #expect(pool.totalSlots == 3) pool.reconcile( diff --git a/TableProTests/Views/Results/SortableHeaderCellTests.swift b/TableProTests/Views/Results/SortableHeaderCellTests.swift index 0927c0b9c..7b4c28273 100644 --- a/TableProTests/Views/Results/SortableHeaderCellTests.swift +++ b/TableProTests/Views/Results/SortableHeaderCellTests.swift @@ -1,8 +1,9 @@ import AppKit import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @MainActor @Suite("SortableHeaderCell") struct SortableHeaderCellTests { @@ -57,6 +58,15 @@ struct SortableHeaderCellTests { #expect(prioritizedWidth < sortedWidth) } + + @Test("Accessibility label includes the visible header comment") + func accessibilityLabelIncludesHeaderComment() { + let cell = SortableHeaderCell(textCell: "email") + cell.headerComment = "Primary contact address" + cell.setAccessibilityLabel("Column: email") + + #expect(cell.accessibilityLabel() == "Column: email, Primary contact address") + } } @MainActor