diff --git a/CHANGELOG.md b/CHANGELOG.md index 231656eb9..9751d9fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Size to Fit** and **Size All Columns to Fit** no longer stretch a column with long text far past the window. A fitted column now stops at half the visible grid, and every column has a maximum width, so a column left oversized before is brought back in range the next time you open the table. - The username is now optional. Leaving it empty, or importing a connection URL that has no user in it, no longer fills in `root`. An empty username lets the database use its own default, the same as `psql` and `mysql` do: your Mac login name on MySQL/MariaDB and PostgreSQL, and `default` on ClickHouse. The `~/.pgpass` lookup now matches on that same user. - A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background. - Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855) diff --git a/TablePro/Views/Results/Cells/DataGridMetrics.swift b/TablePro/Views/Results/Cells/DataGridMetrics.swift index 80e8d29a6..eb4d267d5 100644 --- a/TablePro/Views/Results/Cells/DataGridMetrics.swift +++ b/TablePro/Views/Results/Cells/DataGridMetrics.swift @@ -9,4 +9,6 @@ enum DataGridMetrics { static let cellHorizontalInset: CGFloat = 4 static let rowNumberHeaderPadding: CGFloat = 8 static let rowNumberColumnMinWidth: CGFloat = 40 + static let dataColumnMinWidth: CGFloat = 30 + static let dataColumnMaxWidth: CGFloat = 1_200 } diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index dfb06b653..9a34e2278 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -11,16 +11,17 @@ import TableProPluginKit final class DataGridCellFactory { private static let minColumnWidth: CGFloat = 60 private static let maxColumnWidth: CGFloat = 800 + private static let minFitToContentWidth: CGFloat = 300 + private static let fitToContentViewportFraction: CGFloat = 0.5 private static let sampleRowCount = 30 private static let maxMeasureChars = 50 + private static let headerPadding: CGFloat = 48 + private static let cellPadding: CGFloat = 16 + private static let headerCharWidthRatio: CGFloat = 0.75 - private static let headerFont = NSFont.systemFont(ofSize: 13, weight: .semibold) - - func calculateColumnWidth(for columnName: String) -> CGFloat { - let attributes: [NSAttributedString.Key: Any] = [.font: Self.headerFont] - let size = (columnName as NSString).size(withAttributes: attributes) - let width = size.width + 48 - return min(max(width, Self.minColumnWidth), Self.maxColumnWidth) + static func fitToContentCap(availableWidth: CGFloat) -> CGFloat { + let proportional = availableWidth * fitToContentViewportFraction + return min(max(proportional, minFitToContentWidth), maxColumnWidth) } func calculateOptimalColumnWidth( @@ -28,53 +29,61 @@ final class DataGridCellFactory { columnIndex: Int, tableRows: TableRows ) -> CGFloat { - let headerCharCount = (columnName as NSString).length - var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48 + measureColumnWidth( + for: columnName, + columnIndex: columnIndex, + tableRows: tableRows, + cap: Self.maxColumnWidth, + measuredCharLimit: Self.maxMeasureChars + ) + } - let totalRows = tableRows.count - let columnCount = tableRows.columns.count - let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount - let step = max(1, totalRows / effectiveSampleCount) + func calculateFitToContentWidth( + for columnName: String, + columnIndex: Int, + tableRows: TableRows, + availableWidth: CGFloat + ) -> CGFloat { + let cap = Self.fitToContentCap(availableWidth: availableWidth) let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth - - for i in stride(from: 0, to: totalRows, by: step) { - guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue } - - let charCount = min((value as NSString).length, Self.maxMeasureChars) - let cellWidth = CGFloat(charCount) * charWidth + 16 - maxWidth = max(maxWidth, cellWidth) - - if maxWidth >= Self.maxColumnWidth { - return Self.maxColumnWidth - } - } - - return min(max(maxWidth, Self.minColumnWidth), Self.maxColumnWidth) + let measuredCharLimit = charWidth > 0 ? Int((cap / charWidth).rounded(.up)) : Self.maxMeasureChars + + return measureColumnWidth( + for: columnName, + columnIndex: columnIndex, + tableRows: tableRows, + cap: cap, + measuredCharLimit: measuredCharLimit + ) } - func calculateFitToContentWidth( + private func measureColumnWidth( for columnName: String, columnIndex: Int, - tableRows: TableRows + tableRows: TableRows, + cap: CGFloat, + measuredCharLimit: Int ) -> CGFloat { + let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth let headerCharCount = (columnName as NSString).length - var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48 + var maxWidth = CGFloat(headerCharCount) * charWidth * Self.headerCharWidthRatio + Self.headerPadding let totalRows = tableRows.count - let columnCount = tableRows.columns.count - let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount + let effectiveSampleCount = tableRows.columns.count > 50 ? 10 : Self.sampleRowCount let step = max(1, totalRows / effectiveSampleCount) - let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue } - let charCount = (value as NSString).length - let cellWidth = CGFloat(charCount) * charWidth + 16 - maxWidth = max(maxWidth, cellWidth) + let charCount = min((value as NSString).length, measuredCharLimit) + maxWidth = max(maxWidth, CGFloat(charCount) * charWidth + Self.cellPadding) + + if maxWidth >= cap { + return cap + } } - return max(maxWidth, Self.minColumnWidth) + return min(max(maxWidth, Self.minColumnWidth), cap) } } diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index 088367d45..08bcf3e88 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -84,7 +84,8 @@ final class DataGridColumnPool { while pooledColumns.count < count { let slot = pooledColumns.count let column = NSTableColumn(identifier: ColumnIdentitySchema.slotIdentifier(slot)) - column.minWidth = 30 + column.minWidth = DataGridMetrics.dataColumnMinWidth + column.maxWidth = DataGridMetrics.dataColumnMaxWidth column.resizingMask = .userResizingMask column.isEditable = true column.isHidden = true diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 95a195c9f..2b2aecdbe 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -18,13 +18,25 @@ extension TableViewCoordinator { return column.width } - let tableRows = tableRowsProvider() - let width = cellFactory.calculateFitToContentWidth( + return fitToContentWidth(for: column, dataColumnIndex: dataColumnIndex, tableRows: tableRowsProvider()) + } + + private var visibleGridWidth: CGFloat { + guard let tableView else { return 0 } + return tableView.enclosingScrollView?.contentView.bounds.width ?? tableView.visibleRect.width + } + + private func fitToContentWidth( + for column: NSTableColumn, + dataColumnIndex: Int, + tableRows: TableRows + ) -> CGFloat { + cellFactory.calculateFitToContentWidth( for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, columnIndex: dataColumnIndex, - tableRows: tableRows + tableRows: tableRows, + availableWidth: visibleGridWidth ) - return width } // MARK: - NSMenuDelegate (Header Context Menu) @@ -294,13 +306,11 @@ extension TableViewCoordinator { let column = tableView.tableColumns[columnIndex] guard let dataColumnIndex = dataColumnIndex(from: column.identifier) else { return } - let tableRows = tableRowsProvider() - let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, - columnIndex: dataColumnIndex, - tableRows: tableRows + column.width = fitToContentWidth( + for: column, + dataColumnIndex: dataColumnIndex, + tableRows: tableRowsProvider() ) - column.width = width } @objc func sizeAllColumnsToFit(_ sender: NSMenuItem) { @@ -308,15 +318,16 @@ extension TableViewCoordinator { let tableRows = tableRowsProvider() for column in tableView.tableColumns { - guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier, - let dataColumnIndex = dataColumnIndex(from: column.identifier) else { continue } - - let width = cellFactory.calculateFitToContentWidth( - for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title, - columnIndex: dataColumnIndex, + guard !column.isHidden, + column.identifier != ColumnIdentitySchema.rowNumberIdentifier, + let dataColumnIndex = dataColumnIndex(from: column.identifier), + dataColumnIndex < tableRows.columns.count else { continue } + + column.width = fitToContentWidth( + for: column, + dataColumnIndex: dataColumnIndex, tableRows: tableRows ) - column.width = width } } diff --git a/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift index aaa0864c6..d47a0163f 100644 --- a/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift +++ b/TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift @@ -5,9 +5,10 @@ import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @Suite("Column Width Optimization") @MainActor struct ColumnWidthOptimizationTests { @@ -103,18 +104,6 @@ struct ColumnWidthOptimizationTests { } } - @Test("Width based on header-only method matches expected bounds") - func headerOnlyWidthCalculation() { - let factory = DataGridCellFactory() - - let shortWidth = factory.calculateColumnWidth(for: "id") - #expect(shortWidth >= 60) - - let longWidth = factory.calculateColumnWidth(for: "a_very_long_column_name_that_is_descriptive") - #expect(longWidth > shortWidth) - #expect(longWidth <= 800) - } - @Test("Nil cell values do not crash width calculation") func nilCellValuesSafe() { let factory = DataGridCellFactory() @@ -139,6 +128,71 @@ struct ColumnWidthOptimizationTests { } } +@Suite("Fit To Content Width") +@MainActor +struct FitToContentWidthTests { + private func makeTableRows(values: [String], column: String = "data") -> TableRows { + TableRows.from( + queryRows: values.map { [PluginCellValue.fromOptional($0)] }, + columns: [column], + columnTypes: [.text(rawType: nil)] + ) + } + + private func fitWidth(values: [String], availableWidth: CGFloat, column: String = "data") -> CGFloat { + DataGridCellFactory().calculateFitToContentWidth( + for: column, + columnIndex: 0, + tableRows: makeTableRows(values: values, column: column), + availableWidth: availableWidth + ) + } + + @Test("A very long value is capped instead of stretching the column") + func longValueIsCapped() { + let width = fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 1_600) + + #expect(width == 800, "A 5,000 character value must not widen the column past the 800pt ceiling") + } + + @Test("No column takes more than half the visible grid") + func capIsHalfTheVisibleGrid() { + let width = fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 1_000) + + #expect(width == 500) + } + + @Test("Cap holds at 300pt in a narrow window") + func capFloorsInNarrowWindow() { + #expect(fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 200) == 300) + #expect(fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 0) == 300) + } + + @Test("Short values still size to their content, not to the cap") + func shortValuesSizeToContent() { + let width = fitWidth(values: ["ok", "fine"], availableWidth: 1_600, column: "status") + + #expect(width >= 60) + #expect(width < 300, "The cap is a ceiling, not a target width") + } + + @Test("Fitted width never exceeds the data column ceiling") + func fittedWidthStaysUnderColumnCeiling() { + for availableWidth in [CGFloat](stride(from: 0, through: 4_000, by: 250)) { + let width = fitWidth(values: [String(repeating: "X", count: 20_000)], availableWidth: availableWidth) + #expect(width <= DataGridMetrics.dataColumnMaxWidth) + } + } + + @Test("Long header alone does not stretch the column") + func longHeaderIsCapped() { + let column = String(repeating: "column_name_", count: 200) + let width = fitWidth(values: [], availableWidth: 1_600, column: column) + + #expect(width == 800) + } +} + @Suite("Change Reapplication Version Tracking") struct ChangeReapplyVersionTests { @Test("Version tracking skips redundant work") diff --git a/TableProTests/Views/Results/DataGridColumnPoolTests.swift b/TableProTests/Views/Results/DataGridColumnPoolTests.swift index 394d532cc..a72af268b 100644 --- a/TableProTests/Views/Results/DataGridColumnPoolTests.swift +++ b/TableProTests/Views/Results/DataGridColumnPoolTests.swift @@ -326,6 +326,51 @@ struct DataGridColumnPoolTests { #expect(widthsByName["name"] == 250) } + @Test("An oversized saved width is clamped to the column ceiling") + func reconcile_clampsOversizedSavedWidthToColumnCeiling() { + let pool = DataGridColumnPool() + let tableView = makeTableView() + let schema = ColumnIdentitySchema(columns: ["id", "payload"]) + + var layout = ColumnLayoutState() + layout.columnWidths = ["id": 75, "payload": 28_000] + + pool.reconcile( + tableView: tableView, + schema: schema, + columnTypes: makeColumnTypes(count: 2), + savedLayout: layout, + isEditable: true, + hiddenColumnNames: [], + widthCalculator: defaultWidthCalculator + ) + + let widthsByName = Dictionary(uniqueKeysWithValues: dataColumns(in: tableView).map { ($0.headerCell.stringValue, $0.width) }) + #expect(widthsByName["id"] == 75) + #expect(widthsByName["payload"] == DataGridMetrics.dataColumnMaxWidth) + } + + @Test("Data columns carry the min and max width bounds") + func reconcile_dataColumnsCarryWidthBounds() { + let pool = DataGridColumnPool() + let tableView = makeTableView() + + pool.reconcile( + tableView: tableView, + schema: ColumnIdentitySchema(columns: ["id", "name"]), + columnTypes: makeColumnTypes(count: 2), + savedLayout: nil, + isEditable: true, + hiddenColumnNames: [], + widthCalculator: defaultWidthCalculator + ) + + for column in dataColumns(in: tableView) { + #expect(column.minWidth == DataGridMetrics.dataColumnMinWidth) + #expect(column.maxWidth == DataGridMetrics.dataColumnMaxWidth) + } + } + @Test("reconcile assigns column comments to sortable header cells") func reconcile_assignsColumnCommentsToHeaderCells() throws { let pool = DataGridColumnPool()