diff --git a/CHANGELOG.md b/CHANGELOG.md index fb87083e0..47207c4b9 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 + +- 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 diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index 7432d6e4a..b791cf0ac 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -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 @@ -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.., + hiddenColumnNames: Set + ) -> Bool { + guard !columnComments.isEmpty else { return false } + for slot in 0.. 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 ) @@ -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, @@ -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: diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 43c879b26..861dd4c8c 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -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() { diff --git a/TableProTests/Views/Results/DataGridColumnPoolTests.swift b/TableProTests/Views/Results/DataGridColumnPoolTests.swift index 1aa062ec2..7922ec4b8 100644 --- a/TableProTests/Views/Results/DataGridColumnPoolTests.swift +++ b/TableProTests/Views/Results/DataGridColumnPoolTests.swift @@ -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) + } } diff --git a/TableProTests/Views/Results/SortableHeaderCellTests.swift b/TableProTests/Views/Results/SortableHeaderCellTests.swift index 0927c0b9c..0ea009ede 100644 --- a/TableProTests/Views/Results/SortableHeaderCellTests.swift +++ b/TableProTests/Views/Results/SortableHeaderCellTests.swift @@ -57,6 +57,43 @@ struct SortableHeaderCellTests { #expect(prioritizedWidth < sortedWidth) } + + @Test("Name band fills the bounds when single line") + func nameBandFillsBoundsWhenSingleLine() { + let cell = SortableHeaderCell(textCell: "id") + let bounds = NSRect(x: 0, y: 0, width: 100, height: 24) + + #expect(cell.nameBandRect(forBounds: bounds) == bounds) + } + + @Test("Two-line layout reserves the comment band below the name") + func twoLineLayoutReservesCommentBandBelowName() { + let cell = SortableHeaderCell(textCell: "id") + cell.isTwoLineLayout = true + let bounds = NSRect(x: 0, y: 0, width: 100, height: 38) + + let nameBand = cell.nameBandRect(forBounds: bounds) + + #expect(nameBand.minY == bounds.minY) + #expect(nameBand.height == bounds.height - SortableHeaderCell.commentBandHeight) + #expect(nameBand.maxY < bounds.maxY) + } + + @Test("Funnel aligns to the name band in a two-line header") + func funnelAlignsToNameBandInTwoLineHeader() { + let bounds = NSRect(x: 0, y: 0, width: 100, height: 38) + + let single = SortableHeaderCell(textCell: "id") + let two = SortableHeaderCell(textCell: "id") + two.isTwoLineLayout = true + + let singleFunnel = single.funnelRect(forBounds: bounds) + let twoFunnel = two.funnelRect(forBounds: bounds) + + #expect(singleFunnel.midY == bounds.midY) + #expect(twoFunnel.midY == two.nameBandRect(forBounds: bounds).midY) + #expect(twoFunnel.midY < singleFunnel.midY) + } } @MainActor diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 7095f78b6..550b79a65 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -77,7 +77,7 @@ Same driver for both. MySQL 8.0 defaults to `caching_sha2_password` auth (vs Mar Sidebar shows all accessible databases, tables, structure (columns, indexes, foreign keys), and DDL. Switch databases with **Cmd+K**. -Table and column comments are shown in the UI: the sidebar shows a table's comment in dimmed text after its name (full text on hover), and the data grid column header tooltip includes the column comment. Turn this off from **View > Show Object Comments**. +Table and column comments are shown in the UI: the sidebar shows a table's comment in dimmed text after its name (full text on hover), and the data grid column header shows the column comment on a second line under the name (full text on hover). Turn this off from **View > Show Object Comments**. Full MySQL syntax support for queries: diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index d9f0f0174..bc6e99ea6 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -73,7 +73,7 @@ The database role must be set up for IAM auth (`GRANT rds_iam TO "user"`). Conne Sidebar displays all accessible schemas and tables. Switch databases/schemas with **Cmd+K**. Table info shows structure (columns, indexes, constraints) and DDL. -Table and column comments are shown in the UI: the sidebar shows a table's comment in dimmed text after its name (full text on hover), and the data grid column header tooltip includes the column comment. Turn this off from **View > Show Object Comments**. +Table and column comments are shown in the UI: the sidebar shows a table's comment in dimmed text after its name (full text on hover), and the data grid column header shows the column comment on a second line under the name (full text on hover). Turn this off from **View > Show Object Comments**. Full PostgreSQL syntax support: