diff --git a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift index 9fc74090a..7b6b82452 100644 --- a/TablePro/Views/Results/Cells/DataGridBaseCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridBaseCellView.swift @@ -96,10 +96,13 @@ class DataGridBaseCellView: NSTableCellView { textField = cellTextField addSubview(cellTextField) - textFieldTrailingConstraint = cellTextField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4) + textFieldTrailingConstraint = cellTextField.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -DataGridMetrics.cellHorizontalInset + ) NSLayoutConstraint.activate([ - cellTextField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), + cellTextField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DataGridMetrics.cellHorizontalInset), textFieldTrailingConstraint, cellTextField.centerYAnchor.constraint(equalTo: centerYAnchor), ]) diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index d90f75f98..697f5a64f 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -122,8 +122,14 @@ final class DataGridCellRegistry { cellView.addSubview(cell) NSLayoutConstraint.activate([ - cell.leadingAnchor.constraint(equalTo: cellView.leadingAnchor, constant: 4), - cell.trailingAnchor.constraint(equalTo: cellView.trailingAnchor, constant: -4), + cell.leadingAnchor.constraint( + equalTo: cellView.leadingAnchor, + constant: DataGridMetrics.cellHorizontalInset + ), + cell.trailingAnchor.constraint( + equalTo: cellView.trailingAnchor, + constant: -DataGridMetrics.cellHorizontalInset + ), cell.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), ]) } diff --git a/TablePro/Views/Results/Cells/DataGridChevronCellView.swift b/TablePro/Views/Results/Cells/DataGridChevronCellView.swift index 0bf8947cb..cc1787b0d 100644 --- a/TablePro/Views/Results/Cells/DataGridChevronCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridChevronCellView.swift @@ -11,7 +11,10 @@ class DataGridChevronCellView: DataGridBaseCellView { override func installAccessory() { addSubview(chevronButton) NSLayoutConstraint.activate([ - chevronButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + chevronButton.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -DataGridMetrics.cellHorizontalInset + ), chevronButton.centerYAnchor.constraint(equalTo: centerYAnchor), chevronButton.widthAnchor.constraint(equalToConstant: 10), chevronButton.heightAnchor.constraint(equalToConstant: 12), @@ -34,7 +37,7 @@ class DataGridChevronCellView: DataGridBaseCellView { override func textFieldTrailingInset(for content: DataGridCellContent, state: DataGridCellState) -> CGFloat { let show = state.isEditable && !state.visualState.isDeleted - return show ? -18 : -4 + return show ? -18 : -DataGridMetrics.cellHorizontalInset } @objc diff --git a/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift b/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift index 8f7d6f2a5..602ab1241 100644 --- a/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridForeignKeyCellView.swift @@ -15,7 +15,10 @@ final class DataGridForeignKeyCellView: DataGridBaseCellView { override func installAccessory() { addSubview(fkButton) NSLayoutConstraint.activate([ - fkButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + fkButton.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -DataGridMetrics.cellHorizontalInset + ), fkButton.centerYAnchor.constraint(equalTo: centerYAnchor), fkButton.widthAnchor.constraint(equalToConstant: 16), fkButton.heightAnchor.constraint(equalToConstant: 16), diff --git a/TablePro/Views/Results/Cells/DataGridMetrics.swift b/TablePro/Views/Results/Cells/DataGridMetrics.swift new file mode 100644 index 000000000..9516df836 --- /dev/null +++ b/TablePro/Views/Results/Cells/DataGridMetrics.swift @@ -0,0 +1,10 @@ +// +// DataGridMetrics.swift +// TablePro +// + +import CoreGraphics + +enum DataGridMetrics { + static let cellHorizontalInset: CGFloat = 4 +} diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 78d274025..2f9d04128 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -67,18 +67,7 @@ struct DataGridView: NSViewRepresentable { tableView.action = #selector(TableViewCoordinator.handleClick(_:)) tableView.doubleAction = #selector(TableViewCoordinator.handleDoubleClick(_:)) - let rowNumberColumn = NSTableColumn(identifier: ColumnIdentitySchema.rowNumberIdentifier) - rowNumberColumn.title = "#" - rowNumberColumn.width = 40 - rowNumberColumn.minWidth = 40 - rowNumberColumn.maxWidth = 60 - rowNumberColumn.isEditable = false - rowNumberColumn.resizingMask = [] - let rowNumberHeaderCell = SortableHeaderCell(textCell: "#") - rowNumberHeaderCell.font = rowNumberColumn.headerCell.font - rowNumberHeaderCell.alignment = .right - rowNumberColumn.headerCell = rowNumberHeaderCell - rowNumberColumn.headerCell.setAccessibilityLabel(String(localized: "Row number")) + let rowNumberColumn = Self.makeRowNumberColumn() tableView.addTableColumn(rowNumberColumn) rowNumberColumn.isHidden = !configuration.showRowNumbers @@ -323,6 +312,24 @@ struct DataGridView: NSViewRepresentable { // MARK: - Column Layout Helpers + @MainActor + static func makeRowNumberColumn() -> NSTableColumn { + let column = NSTableColumn(identifier: ColumnIdentitySchema.rowNumberIdentifier) + column.title = "#" + column.width = 40 + column.minWidth = 40 + column.maxWidth = 60 + column.isEditable = false + column.resizingMask = [] + let defaultHeaderFont = column.headerCell.font + let headerCell = SortableHeaderCell(textCell: "#") + headerCell.font = defaultHeaderFont + headerCell.alignment = .right + headerCell.setAccessibilityLabel(String(localized: "Row number")) + column.headerCell = headerCell + return column + } + static let firstDataTableColumnIndex: Int = 1 static func isDataTableColumn(_ tableColumnIndex: Int) -> Bool { diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index e0683a7c5..28cb6a8d0 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -13,7 +13,7 @@ final class SortableHeaderCell: NSTableHeaderCell { private static let indicatorPadding: CGFloat = 4 private static let indicatorSpacing: CGFloat = 2 private static let priorityFontSize: CGFloat = 9 - private static let titleHorizontalPadding: CGFloat = 4 + private static let defaultIndicatorSize = NSSize(width: 9, height: 6) override init(textCell string: String) { super.init(textCell: string) @@ -30,27 +30,12 @@ final class SortableHeaderCell: NSTableHeaderCell { } override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) { - guard let direction = sortDirection else { - drawTitle(in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: false)) - return - } + drawTitle(in: titleRect(forBounds: cellFrame), font: titleFont(isSorted: sortDirection != nil)) - let indicatorImage = Self.indicatorImage(for: direction) - let indicatorSize = indicatorImage?.size ?? NSSize(width: 9, height: 6) - let priorityText = priorityNumberString() - let priorityWidth = priorityText.map { Self.measureWidth(of: $0) } ?? 0 - let reservedWidth = indicatorSize.width - + Self.indicatorPadding * 2 - + (priorityText == nil ? 0 : priorityWidth + Self.indicatorSpacing) - - let titleFrame = NSRect( - x: cellFrame.minX, - y: cellFrame.minY, - width: max(0, cellFrame.width - reservedWidth), - height: cellFrame.height - ) - drawTitle(in: titleRect(forBounds: titleFrame), font: titleFont(isSorted: true)) + guard let direction = sortDirection else { return } + let indicatorImage = Self.indicatorImage(for: direction) + let indicatorSize = indicatorImage?.size ?? Self.defaultIndicatorSize let indicatorOriginX = cellFrame.maxX - Self.indicatorPadding - indicatorSize.width let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2 let indicatorRect = NSRect( @@ -61,7 +46,8 @@ final class SortableHeaderCell: NSTableHeaderCell { ) Self.drawIndicator(image: indicatorImage, in: indicatorRect) - if let priorityText { + if let priorityText = priorityNumberString() { + let priorityWidth = Self.measureWidth(of: priorityText) let textOriginX = indicatorOriginX - Self.indicatorSpacing - priorityWidth let textRect = NSRect( x: textOriginX, @@ -74,15 +60,25 @@ final class SortableHeaderCell: NSTableHeaderCell { } override func titleRect(forBounds rect: NSRect) -> NSRect { - let inset = min(Self.titleHorizontalPadding, rect.width / 2) + let inset = min(DataGridMetrics.cellHorizontalInset, rect.width / 2) + let availableWidth = max(0, rect.width - inset * 2 - reservedTrailingWidth()) return NSRect( x: rect.minX + inset, y: rect.minY, - width: max(0, rect.width - inset * 2), + width: availableWidth, height: rect.height ) } + private func reservedTrailingWidth() -> CGFloat { + guard let direction = sortDirection else { return 0 } + let indicatorWidth = Self.indicatorImage(for: direction)?.size.width + ?? Self.defaultIndicatorSize.width + let priorityText = priorityNumberString() + let priorityComponent = priorityText.map { Self.measureWidth(of: $0) + Self.indicatorSpacing } ?? 0 + return indicatorWidth + Self.indicatorPadding * 2 + priorityComponent + } + private func titleFont(isSorted: Bool) -> NSFont { let baseFont = font ?? NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) guard isSorted else { return baseFont } diff --git a/TableProTests/Views/Results/SortableHeaderCellTests.swift b/TableProTests/Views/Results/SortableHeaderCellTests.swift index 00972b872..c9dac40ee 100644 --- a/TableProTests/Views/Results/SortableHeaderCellTests.swift +++ b/TableProTests/Views/Results/SortableHeaderCellTests.swift @@ -22,4 +22,51 @@ struct SortableHeaderCellTests { #expect(titleRect.minX == 3) #expect(titleRect.width == 0) } + + @Test("Sorted title rect reserves trailing space for the indicator") + func sortedTitleRectReservesTrailingSpaceForIndicator() { + let bounds = NSRect(x: 0, y: 0, width: 100, height: 24) + + let unsorted = SortableHeaderCell(textCell: "id") + let sorted = SortableHeaderCell(textCell: "id") + sorted.sortDirection = .ascending + + let unsortedRect = unsorted.titleRect(forBounds: bounds) + let sortedRect = sorted.titleRect(forBounds: bounds) + + #expect(sortedRect.minX == unsortedRect.minX) + #expect(sortedRect.width < unsortedRect.width) + #expect(sortedRect.maxX <= bounds.maxX - DataGridMetrics.cellHorizontalInset) + } + + @Test("Priority badge shrinks the sorted title rect further") + func priorityBadgeShrinksSortedTitleRectFurther() { + let bounds = NSRect(x: 0, y: 0, width: 100, height: 24) + + let sorted = SortableHeaderCell(textCell: "id") + sorted.sortDirection = .ascending + + let prioritized = SortableHeaderCell(textCell: "id") + prioritized.sortDirection = .ascending + prioritized.sortPriority = 2 + + let sortedWidth = sorted.titleRect(forBounds: bounds).width + let prioritizedWidth = prioritized.titleRect(forBounds: bounds).width + + #expect(prioritizedWidth < sortedWidth) + } +} + +@MainActor +@Suite("DataGridView.makeRowNumberColumn") +struct DataGridRowNumberColumnTests { + @Test("Row-number column header uses a right-aligned SortableHeaderCell") + func rowNumberHeaderIsRightAlignedSortableCell() throws { + let column = DataGridView.makeRowNumberColumn() + + let headerCell = try #require(column.headerCell as? SortableHeaderCell) + #expect(headerCell.alignment == .right) + #expect(column.identifier == ColumnIdentitySchema.rowNumberIdentifier) + #expect(column.title == "#") + } }