Skip to content

Commit 68ba2f3

Browse files
authored
fix(datagrid): cap the width of a column sized to fit its content (#1859)
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 2d2da54 commit 68ba2f3

7 files changed

Lines changed: 191 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323

2424
### Fixed
2525

26+
- **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.
2627
- 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.
2728
- A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background.
2829
- Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855)

TablePro/Views/Results/Cells/DataGridMetrics.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ enum DataGridMetrics {
99
static let cellHorizontalInset: CGFloat = 4
1010
static let rowNumberHeaderPadding: CGFloat = 8
1111
static let rowNumberColumnMinWidth: CGFloat = 40
12+
static let dataColumnMinWidth: CGFloat = 30
13+
static let dataColumnMaxWidth: CGFloat = 1_200
1214
}

TablePro/Views/Results/DataGridCellFactory.swift

Lines changed: 46 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,70 +11,79 @@ import TableProPluginKit
1111
final class DataGridCellFactory {
1212
private static let minColumnWidth: CGFloat = 60
1313
private static let maxColumnWidth: CGFloat = 800
14+
private static let minFitToContentWidth: CGFloat = 300
15+
private static let fitToContentViewportFraction: CGFloat = 0.5
1416
private static let sampleRowCount = 30
1517
private static let maxMeasureChars = 50
18+
private static let headerPadding: CGFloat = 48
19+
private static let cellPadding: CGFloat = 16
20+
private static let headerCharWidthRatio: CGFloat = 0.75
1621

17-
private static let headerFont = NSFont.systemFont(ofSize: 13, weight: .semibold)
18-
19-
func calculateColumnWidth(for columnName: String) -> CGFloat {
20-
let attributes: [NSAttributedString.Key: Any] = [.font: Self.headerFont]
21-
let size = (columnName as NSString).size(withAttributes: attributes)
22-
let width = size.width + 48
23-
return min(max(width, Self.minColumnWidth), Self.maxColumnWidth)
22+
static func fitToContentCap(availableWidth: CGFloat) -> CGFloat {
23+
let proportional = availableWidth * fitToContentViewportFraction
24+
return min(max(proportional, minFitToContentWidth), maxColumnWidth)
2425
}
2526

2627
func calculateOptimalColumnWidth(
2728
for columnName: String,
2829
columnIndex: Int,
2930
tableRows: TableRows
3031
) -> CGFloat {
31-
let headerCharCount = (columnName as NSString).length
32-
var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48
32+
measureColumnWidth(
33+
for: columnName,
34+
columnIndex: columnIndex,
35+
tableRows: tableRows,
36+
cap: Self.maxColumnWidth,
37+
measuredCharLimit: Self.maxMeasureChars
38+
)
39+
}
3340

34-
let totalRows = tableRows.count
35-
let columnCount = tableRows.columns.count
36-
let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount
37-
let step = max(1, totalRows / effectiveSampleCount)
41+
func calculateFitToContentWidth(
42+
for columnName: String,
43+
columnIndex: Int,
44+
tableRows: TableRows,
45+
availableWidth: CGFloat
46+
) -> CGFloat {
47+
let cap = Self.fitToContentCap(availableWidth: availableWidth)
3848
let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth
39-
40-
for i in stride(from: 0, to: totalRows, by: step) {
41-
guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue }
42-
43-
let charCount = min((value as NSString).length, Self.maxMeasureChars)
44-
let cellWidth = CGFloat(charCount) * charWidth + 16
45-
maxWidth = max(maxWidth, cellWidth)
46-
47-
if maxWidth >= Self.maxColumnWidth {
48-
return Self.maxColumnWidth
49-
}
50-
}
51-
52-
return min(max(maxWidth, Self.minColumnWidth), Self.maxColumnWidth)
49+
let measuredCharLimit = charWidth > 0 ? Int((cap / charWidth).rounded(.up)) : Self.maxMeasureChars
50+
51+
return measureColumnWidth(
52+
for: columnName,
53+
columnIndex: columnIndex,
54+
tableRows: tableRows,
55+
cap: cap,
56+
measuredCharLimit: measuredCharLimit
57+
)
5358
}
5459

55-
func calculateFitToContentWidth(
60+
private func measureColumnWidth(
5661
for columnName: String,
5762
columnIndex: Int,
58-
tableRows: TableRows
63+
tableRows: TableRows,
64+
cap: CGFloat,
65+
measuredCharLimit: Int
5966
) -> CGFloat {
67+
let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth
6068
let headerCharCount = (columnName as NSString).length
61-
var maxWidth = CGFloat(headerCharCount) * ThemeEngine.shared.dataGridFonts.monoCharWidth * 0.75 + 48
69+
var maxWidth = CGFloat(headerCharCount) * charWidth * Self.headerCharWidthRatio + Self.headerPadding
6270

6371
let totalRows = tableRows.count
64-
let columnCount = tableRows.columns.count
65-
let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount
72+
let effectiveSampleCount = tableRows.columns.count > 50 ? 10 : Self.sampleRowCount
6673
let step = max(1, totalRows / effectiveSampleCount)
67-
let charWidth = ThemeEngine.shared.dataGridFonts.monoCharWidth
6874

6975
for i in stride(from: 0, to: totalRows, by: step) {
7076
guard let value = tableRows.value(at: i, column: columnIndex).asText else { continue }
7177

72-
let charCount = (value as NSString).length
73-
let cellWidth = CGFloat(charCount) * charWidth + 16
74-
maxWidth = max(maxWidth, cellWidth)
78+
let charCount = min((value as NSString).length, measuredCharLimit)
79+
maxWidth = max(maxWidth, CGFloat(charCount) * charWidth + Self.cellPadding)
80+
81+
if maxWidth >= cap {
82+
return cap
83+
}
7584
}
7685

77-
return max(maxWidth, Self.minColumnWidth)
86+
return min(max(maxWidth, Self.minColumnWidth), cap)
7887
}
7988
}
8089

TablePro/Views/Results/DataGridColumnPool.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ final class DataGridColumnPool {
8484
while pooledColumns.count < count {
8585
let slot = pooledColumns.count
8686
let column = NSTableColumn(identifier: ColumnIdentitySchema.slotIdentifier(slot))
87-
column.minWidth = 30
87+
column.minWidth = DataGridMetrics.dataColumnMinWidth
88+
column.maxWidth = DataGridMetrics.dataColumnMaxWidth
8889
column.resizingMask = .userResizingMask
8990
column.isEditable = true
9091
column.isHidden = true

TablePro/Views/Results/Extensions/DataGridView+Sort.swift

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,25 @@ extension TableViewCoordinator {
1818
return column.width
1919
}
2020

21-
let tableRows = tableRowsProvider()
22-
let width = cellFactory.calculateFitToContentWidth(
21+
return fitToContentWidth(for: column, dataColumnIndex: dataColumnIndex, tableRows: tableRowsProvider())
22+
}
23+
24+
private var visibleGridWidth: CGFloat {
25+
guard let tableView else { return 0 }
26+
return tableView.enclosingScrollView?.contentView.bounds.width ?? tableView.visibleRect.width
27+
}
28+
29+
private func fitToContentWidth(
30+
for column: NSTableColumn,
31+
dataColumnIndex: Int,
32+
tableRows: TableRows
33+
) -> CGFloat {
34+
cellFactory.calculateFitToContentWidth(
2335
for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title,
2436
columnIndex: dataColumnIndex,
25-
tableRows: tableRows
37+
tableRows: tableRows,
38+
availableWidth: visibleGridWidth
2639
)
27-
return width
2840
}
2941

3042
// MARK: - NSMenuDelegate (Header Context Menu)
@@ -294,29 +306,28 @@ extension TableViewCoordinator {
294306
let column = tableView.tableColumns[columnIndex]
295307
guard let dataColumnIndex = dataColumnIndex(from: column.identifier) else { return }
296308

297-
let tableRows = tableRowsProvider()
298-
let width = cellFactory.calculateFitToContentWidth(
299-
for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title,
300-
columnIndex: dataColumnIndex,
301-
tableRows: tableRows
309+
column.width = fitToContentWidth(
310+
for: column,
311+
dataColumnIndex: dataColumnIndex,
312+
tableRows: tableRowsProvider()
302313
)
303-
column.width = width
304314
}
305315

306316
@objc func sizeAllColumnsToFit(_ sender: NSMenuItem) {
307317
guard let tableView else { return }
308318

309319
let tableRows = tableRowsProvider()
310320
for column in tableView.tableColumns {
311-
guard column.identifier != ColumnIdentitySchema.rowNumberIdentifier,
312-
let dataColumnIndex = dataColumnIndex(from: column.identifier) else { continue }
313-
314-
let width = cellFactory.calculateFitToContentWidth(
315-
for: dataColumnIndex < tableRows.columns.count ? tableRows.columns[dataColumnIndex] : column.title,
316-
columnIndex: dataColumnIndex,
321+
guard !column.isHidden,
322+
column.identifier != ColumnIdentitySchema.rowNumberIdentifier,
323+
let dataColumnIndex = dataColumnIndex(from: column.identifier),
324+
dataColumnIndex < tableRows.columns.count else { continue }
325+
326+
column.width = fitToContentWidth(
327+
for: column,
328+
dataColumnIndex: dataColumnIndex,
317329
tableRows: tableRows
318330
)
319-
column.width = width
320331
}
321332
}
322333

TableProTests/Views/Results/DataGridCellFactoryPerfTests.swift

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55

66
import Foundation
77
import TableProPluginKit
8-
@testable import TablePro
98
import Testing
109

10+
@testable import TablePro
11+
1112
@Suite("Column Width Optimization")
1213
@MainActor
1314
struct ColumnWidthOptimizationTests {
@@ -103,18 +104,6 @@ struct ColumnWidthOptimizationTests {
103104
}
104105
}
105106

106-
@Test("Width based on header-only method matches expected bounds")
107-
func headerOnlyWidthCalculation() {
108-
let factory = DataGridCellFactory()
109-
110-
let shortWidth = factory.calculateColumnWidth(for: "id")
111-
#expect(shortWidth >= 60)
112-
113-
let longWidth = factory.calculateColumnWidth(for: "a_very_long_column_name_that_is_descriptive")
114-
#expect(longWidth > shortWidth)
115-
#expect(longWidth <= 800)
116-
}
117-
118107
@Test("Nil cell values do not crash width calculation")
119108
func nilCellValuesSafe() {
120109
let factory = DataGridCellFactory()
@@ -139,6 +128,71 @@ struct ColumnWidthOptimizationTests {
139128
}
140129
}
141130

131+
@Suite("Fit To Content Width")
132+
@MainActor
133+
struct FitToContentWidthTests {
134+
private func makeTableRows(values: [String], column: String = "data") -> TableRows {
135+
TableRows.from(
136+
queryRows: values.map { [PluginCellValue.fromOptional($0)] },
137+
columns: [column],
138+
columnTypes: [.text(rawType: nil)]
139+
)
140+
}
141+
142+
private func fitWidth(values: [String], availableWidth: CGFloat, column: String = "data") -> CGFloat {
143+
DataGridCellFactory().calculateFitToContentWidth(
144+
for: column,
145+
columnIndex: 0,
146+
tableRows: makeTableRows(values: values, column: column),
147+
availableWidth: availableWidth
148+
)
149+
}
150+
151+
@Test("A very long value is capped instead of stretching the column")
152+
func longValueIsCapped() {
153+
let width = fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 1_600)
154+
155+
#expect(width == 800, "A 5,000 character value must not widen the column past the 800pt ceiling")
156+
}
157+
158+
@Test("No column takes more than half the visible grid")
159+
func capIsHalfTheVisibleGrid() {
160+
let width = fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 1_000)
161+
162+
#expect(width == 500)
163+
}
164+
165+
@Test("Cap holds at 300pt in a narrow window")
166+
func capFloorsInNarrowWindow() {
167+
#expect(fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 200) == 300)
168+
#expect(fitWidth(values: [String(repeating: "X", count: 5_000)], availableWidth: 0) == 300)
169+
}
170+
171+
@Test("Short values still size to their content, not to the cap")
172+
func shortValuesSizeToContent() {
173+
let width = fitWidth(values: ["ok", "fine"], availableWidth: 1_600, column: "status")
174+
175+
#expect(width >= 60)
176+
#expect(width < 300, "The cap is a ceiling, not a target width")
177+
}
178+
179+
@Test("Fitted width never exceeds the data column ceiling")
180+
func fittedWidthStaysUnderColumnCeiling() {
181+
for availableWidth in [CGFloat](stride(from: 0, through: 4_000, by: 250)) {
182+
let width = fitWidth(values: [String(repeating: "X", count: 20_000)], availableWidth: availableWidth)
183+
#expect(width <= DataGridMetrics.dataColumnMaxWidth)
184+
}
185+
}
186+
187+
@Test("Long header alone does not stretch the column")
188+
func longHeaderIsCapped() {
189+
let column = String(repeating: "column_name_", count: 200)
190+
let width = fitWidth(values: [], availableWidth: 1_600, column: column)
191+
192+
#expect(width == 800)
193+
}
194+
}
195+
142196
@Suite("Change Reapplication Version Tracking")
143197
struct ChangeReapplyVersionTests {
144198
@Test("Version tracking skips redundant work")

TableProTests/Views/Results/DataGridColumnPoolTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,51 @@ struct DataGridColumnPoolTests {
326326
#expect(widthsByName["name"] == 250)
327327
}
328328

329+
@Test("An oversized saved width is clamped to the column ceiling")
330+
func reconcile_clampsOversizedSavedWidthToColumnCeiling() {
331+
let pool = DataGridColumnPool()
332+
let tableView = makeTableView()
333+
let schema = ColumnIdentitySchema(columns: ["id", "payload"])
334+
335+
var layout = ColumnLayoutState()
336+
layout.columnWidths = ["id": 75, "payload": 28_000]
337+
338+
pool.reconcile(
339+
tableView: tableView,
340+
schema: schema,
341+
columnTypes: makeColumnTypes(count: 2),
342+
savedLayout: layout,
343+
isEditable: true,
344+
hiddenColumnNames: [],
345+
widthCalculator: defaultWidthCalculator
346+
)
347+
348+
let widthsByName = Dictionary(uniqueKeysWithValues: dataColumns(in: tableView).map { ($0.headerCell.stringValue, $0.width) })
349+
#expect(widthsByName["id"] == 75)
350+
#expect(widthsByName["payload"] == DataGridMetrics.dataColumnMaxWidth)
351+
}
352+
353+
@Test("Data columns carry the min and max width bounds")
354+
func reconcile_dataColumnsCarryWidthBounds() {
355+
let pool = DataGridColumnPool()
356+
let tableView = makeTableView()
357+
358+
pool.reconcile(
359+
tableView: tableView,
360+
schema: ColumnIdentitySchema(columns: ["id", "name"]),
361+
columnTypes: makeColumnTypes(count: 2),
362+
savedLayout: nil,
363+
isEditable: true,
364+
hiddenColumnNames: [],
365+
widthCalculator: defaultWidthCalculator
366+
)
367+
368+
for column in dataColumns(in: tableView) {
369+
#expect(column.minWidth == DataGridMetrics.dataColumnMinWidth)
370+
#expect(column.maxWidth == DataGridMetrics.dataColumnMaxWidth)
371+
}
372+
}
373+
329374
@Test("reconcile assigns column comments to sortable header cells")
330375
func reconcile_assignsColumnCommentsToHeaderCells() throws {
331376
let pool = DataGridColumnPool()

0 commit comments

Comments
 (0)