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

- 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.
Expand Down
20 changes: 20 additions & 0 deletions TablePro/Views/Results/DataGridColumnPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ final class DataGridColumnPool {
column.isHidden = true
}
}
updateHeaderHeight(in: tableView, showsComments: hasVisibleComments(visibleCount: visibleCount))

let targetOrder = computeTargetOrder(
visibleCount: visibleCount,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
63 changes: 58 additions & 5 deletions TablePro/Views/Results/SortableHeaderCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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: ", ")
}

Expand Down
36 changes: 36 additions & 0 deletions TablePro/Views/Results/SortableHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
78 changes: 76 additions & 2 deletions TableProTests/Views/Results/DataGridColumnPoolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,14 +318,88 @@ 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) })
#expect(widthsByName["id"] == 75)
#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()
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 11 additions & 1 deletion TableProTests/Views/Results/SortableHeaderCellTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import AppKit
import TableProPluginKit
@testable import TablePro
import Testing

@testable import TablePro

@MainActor
@Suite("SortableHeaderCell")
struct SortableHeaderCellTests {
Expand Down Expand Up @@ -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
Expand Down
Loading