Skip to content

Commit 295a7ab

Browse files
authored
feat: add configurable font family and size for data grid (#303)
* feat: add configurable font family and size for data grid * fix: use partial reload for data grid settings changes to prevent stale data * fix: update data grid fonts in-place to avoid stale cell data on reload * fix: address review feedback for data grid font setting
1 parent 6c3da7d commit 295a7ab

11 files changed

Lines changed: 280 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3333
- SQL import options (wrap in transaction, disable FK checks) now persist across launches
3434
- `needsRestart` banner persists across app quit/relaunch after plugin uninstall
3535
- Copy as INSERT/UPDATE SQL statements from data grid context menu
36+
- Configurable font family and size for data grid (Settings > Data Grid > Font)
3637
- Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour
3738
- MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support
3839
- `~/.pgpass` file support for PostgreSQL/Redshift connections with live validation in the connection form

TablePro/Core/Storage/AppSettingsManager.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ final class AppSettingsManager {
6060
storage.saveDataGrid(validated)
6161
// Update date formatting service with new format
6262
DateFormattingService.shared.updateFormat(validated.dateFormat)
63+
DataGridFontCache.reloadFromSettings(validated)
6364
notifyChange(.dataGridSettingsDidChange)
6465
}
6566
}
@@ -135,6 +136,8 @@ final class AppSettingsManager {
135136
// Initialize DateFormattingService with current format
136137
DateFormattingService.shared.updateFormat(dataGrid.dateFormat)
137138

139+
DataGridFontCache.reloadFromSettings(dataGrid)
140+
138141
// Observe system accessibility text size changes and re-apply editor fonts
139142
observeAccessibilityTextSizeChanges()
140143
}
@@ -169,6 +172,8 @@ final class AppSettingsManager {
169172
Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))")
170173
// Re-apply editor fonts with the updated accessibility scale factor
171174
SQLEditorTheme.reloadFromSettings(editor)
175+
DataGridFontCache.reloadFromSettings(dataGrid)
176+
notifyChange(.dataGridSettingsDidChange)
172177
// Notify the editor view to rebuild its configuration
173178
NotificationCenter.default.post(name: .accessibilityTextSizeDidChange, object: self)
174179
}

TablePro/Models/Settings/AppSettings.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ enum DateFormatOption: String, Codable, CaseIterable, Identifiable {
354354

355355
/// Data grid settings
356356
struct DataGridSettings: Codable, Equatable {
357+
var fontFamily: EditorFont
358+
var fontSize: Int
357359
var rowHeight: DataGridRowHeight
358360
var dateFormat: DateFormatOption
359361
var nullDisplay: String
@@ -364,13 +366,17 @@ struct DataGridSettings: Codable, Equatable {
364366
static let `default` = DataGridSettings()
365367

366368
init(
369+
fontFamily: EditorFont = .systemMono,
370+
fontSize: Int = 13,
367371
rowHeight: DataGridRowHeight = .normal,
368372
dateFormat: DateFormatOption = .iso8601,
369373
nullDisplay: String = "NULL",
370374
defaultPageSize: Int = 1_000,
371375
showAlternateRows: Bool = true,
372376
autoShowInspector: Bool = false
373377
) {
378+
self.fontFamily = fontFamily
379+
self.fontSize = fontSize
374380
self.rowHeight = rowHeight
375381
self.dateFormat = dateFormat
376382
self.nullDisplay = nullDisplay
@@ -381,6 +387,8 @@ struct DataGridSettings: Codable, Equatable {
381387

382388
init(from decoder: Decoder) throws {
383389
let container = try decoder.container(keyedBy: CodingKeys.self)
390+
fontFamily = try container.decodeIfPresent(EditorFont.self, forKey: .fontFamily) ?? .systemMono
391+
fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 13
384392
rowHeight = try container.decode(DataGridRowHeight.self, forKey: .rowHeight)
385393
dateFormat = try container.decode(DateFormatOption.self, forKey: .dateFormat)
386394
nullDisplay = try container.decode(String.self, forKey: .nullDisplay)
@@ -389,6 +397,11 @@ struct DataGridSettings: Codable, Equatable {
389397
autoShowInspector = try container.decodeIfPresent(Bool.self, forKey: .autoShowInspector) ?? false
390398
}
391399

400+
/// Clamped font size (10-18)
401+
var clampedFontSize: Int {
402+
min(max(fontSize, 10), 18)
403+
}
404+
392405
// MARK: - Validated Properties
393406

394407
/// Validated and sanitized nullDisplay (max 20 chars, no newlines)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// DataGridFontCache.swift
3+
// TablePro
4+
//
5+
// Cached font variants for the data grid.
6+
// Updated via reloadFromSettings() when user changes font preferences.
7+
//
8+
9+
import AppKit
10+
11+
/// Tags stored on NSTextField.tag to identify which font variant a cell uses.
12+
/// Used by `updateVisibleCellFonts` to re-apply the correct variant after a font change.
13+
enum DataGridFontVariant {
14+
static let regular = 0
15+
static let italic = 1
16+
static let medium = 2
17+
static let rowNumber = 3
18+
}
19+
20+
@MainActor
21+
struct DataGridFontCache {
22+
private(set) static var regular = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
23+
private(set) static var italic = regular.withTraits(.italic)
24+
private(set) static var medium = NSFont.monospacedSystemFont(ofSize: 13, weight: .medium)
25+
private(set) static var rowNumber = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular)
26+
private(set) static var measureFont = regular
27+
private(set) static var monoCharWidth: CGFloat = {
28+
let attrs: [NSAttributedString.Key: Any] = [.font: regular]
29+
return ("M" as NSString).size(withAttributes: attrs).width
30+
}()
31+
32+
@MainActor
33+
static func reloadFromSettings(_ settings: DataGridSettings) {
34+
let scale = SQLEditorTheme.accessibilityScaleFactor
35+
let scaledSize = round(CGFloat(settings.clampedFontSize) * scale)
36+
regular = settings.fontFamily.font(size: scaledSize)
37+
italic = regular.withTraits(.italic)
38+
medium = NSFontManager.shared.convert(regular, toHaveTrait: .boldFontMask)
39+
let rowNumSize = max(round(scaledSize - 1), 9)
40+
rowNumber = NSFont.monospacedDigitSystemFont(ofSize: rowNumSize, weight: .regular)
41+
measureFont = regular
42+
let attrs: [NSAttributedString.Key: Any] = [.font: regular]
43+
monoCharWidth = ("M" as NSString).size(withAttributes: attrs).width
44+
}
45+
}

TablePro/Views/Results/CellOverlayEditor.swift

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
5656
let cellRect = cellView.convert(cellView.bounds, to: tableView)
5757

5858
// Determine overlay height — at least the cell height, up to 120pt
59-
let lineHeight: CGFloat = CellOverlayFonts.regular.boundingRectForFont.height + 4
59+
let lineHeight: CGFloat = DataGridFontCache.regular.boundingRectForFont.height + 4
6060
let lineCount = CGFloat(value.components(separatedBy: .newlines).count)
6161
let contentHeight = max(lineCount * lineHeight + 8, cellRect.height)
6262
let overlayHeight = min(contentHeight, 120)
@@ -73,7 +73,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
7373
textView.overlayEditor = self
7474
textView.isRichText = false
7575
textView.allowsUndo = true
76-
textView.font = CellOverlayFonts.regular
76+
textView.font = DataGridFontCache.regular
7777
textView.textColor = .labelColor
7878
textView.backgroundColor = .textBackgroundColor
7979
textView.isVerticallyResizable = true
@@ -216,15 +216,6 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate {
216216
// Up/Down arrows — let NSTextView handle natively for line navigation
217217
return false
218218
}
219-
220-
// MARK: - Fonts
221-
222-
private enum CellOverlayFonts {
223-
static let regular = NSFont.monospacedSystemFont(
224-
ofSize: DesignConstants.FontSize.body,
225-
weight: .regular
226-
)
227-
}
228219
}
229220

230221
// MARK: - Overlay Text View

TablePro/Views/Results/DataGridCellFactory.swift

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,6 @@ final class DataGridCellFactory {
7070
}
7171
}
7272

73-
// MARK: - Cached Fonts (avoid recreation per cell render)
74-
75-
private enum CellFonts {
76-
static let regular = NSFont.monospacedSystemFont(
77-
ofSize: DesignConstants.FontSize.body,
78-
weight: .regular
79-
)
80-
static let italic = regular.withTraits(.italic)
81-
static let medium = NSFont.monospacedSystemFont(
82-
ofSize: DesignConstants.FontSize.body,
83-
weight: .medium
84-
)
85-
static let rowNumber = NSFont.monospacedDigitSystemFont(
86-
ofSize: DesignConstants.FontSize.medium,
87-
weight: .regular
88-
)
89-
}
90-
9173
// MARK: - Cached Colors (avoid allocation per cell render)
9274

9375
private enum CellColors {
@@ -114,13 +96,15 @@ final class DataGridCellFactory {
11496
let textField = reused.textField {
11597
cellView = reused
11698
cell = textField
99+
cell.font = DataGridFontCache.rowNumber
117100
} else {
118101
cellView = NSTableCellView()
119102
cellView.identifier = cellViewId
120103

121104
cell = NSTextField(labelWithString: "")
122105
cell.alignment = .right
123-
cell.font = CellFonts.rowNumber
106+
cell.font = DataGridFontCache.rowNumber
107+
cell.tag = DataGridFontVariant.rowNumber
124108
cell.textColor = .secondaryLabelColor
125109
cell.translatesAutoresizingMaskIntoConstraints = false
126110

@@ -194,7 +178,7 @@ final class DataGridCellFactory {
194178
cellView.canDrawSubviewsIntoLayer = true
195179

196180
cell = CellTextField()
197-
cell.font = CellFonts.regular
181+
cell.font = DataGridFontCache.regular
198182
cell.drawsBackground = false
199183
cell.isBordered = false
200184
cell.focusRingType = .none
@@ -330,35 +314,28 @@ final class DataGridCellFactory {
330314

331315
if value == nil {
332316
cell.stringValue = ""
317+
cell.font = DataGridFontCache.italic
318+
cell.tag = DataGridFontVariant.italic
333319
if !isLargeDataset {
334320
cell.placeholderString = nullDisplayString
335-
cell.textColor = .secondaryLabelColor
336-
if cell.font !== CellFonts.italic {
337-
cell.font = CellFonts.italic
338-
}
339-
} else {
340-
cell.textColor = .secondaryLabelColor
341321
}
322+
cell.textColor = .secondaryLabelColor
342323
} else if value == "__DEFAULT__" {
343324
cell.stringValue = ""
325+
cell.font = DataGridFontCache.medium
326+
cell.tag = DataGridFontVariant.medium
344327
if !isLargeDataset {
345328
cell.placeholderString = "DEFAULT"
346-
cell.textColor = .systemBlue
347-
cell.font = CellFonts.medium
348-
} else {
349-
cell.textColor = .systemBlue
350329
}
330+
cell.textColor = .systemBlue
351331
} else if value == "" {
352332
cell.stringValue = ""
333+
cell.font = DataGridFontCache.italic
334+
cell.tag = DataGridFontVariant.italic
353335
if !isLargeDataset {
354336
cell.placeholderString = "Empty"
355-
cell.textColor = .secondaryLabelColor
356-
if cell.font !== CellFonts.italic {
357-
cell.font = CellFonts.italic
358-
}
359-
} else {
360-
cell.textColor = .secondaryLabelColor
361337
}
338+
cell.textColor = .secondaryLabelColor
362339
} else {
363340
var displayValue = value ?? ""
364341

@@ -378,9 +355,8 @@ final class DataGridCellFactory {
378355
cell.stringValue = displayValue
379356
(cell as? CellTextField)?.originalValue = value
380357
cell.textColor = .labelColor
381-
if cell.font !== CellFonts.regular {
382-
cell.font = CellFonts.regular
383-
}
358+
cell.font = DataGridFontCache.regular
359+
cell.tag = DataGridFontVariant.regular
384360
}
385361
}
386362

@@ -394,13 +370,6 @@ final class DataGridCellFactory {
394370
private static let sampleRowCount = 30
395371
/// Maximum characters to consider per cell for width estimation
396372
private static let maxMeasureChars = 50
397-
/// Font for measuring cell content (monospaced — all glyphs have equal advance)
398-
private static let measureFont = NSFont.monospacedSystemFont(ofSize: DesignConstants.FontSize.body, weight: .regular)
399-
/// Pre-computed advance width of a single monospaced glyph (avoids per-row CoreText calls)
400-
private static let monoCharWidth: CGFloat = {
401-
let attrs: [NSAttributedString.Key: Any] = [.font: measureFont]
402-
return ("M" as NSString).size(withAttributes: attrs).width
403-
}()
404373
/// Font for measuring header
405374
private static let headerFont = NSFont.systemFont(ofSize: DesignConstants.FontSize.body, weight: .semibold)
406375

@@ -432,14 +401,14 @@ final class DataGridCellFactory {
432401
// instead of CoreText measurement. ~0.6 of mono width is a good estimate
433402
// for proportional system font.
434403
let headerCharCount = (columnName as NSString).length
435-
var maxWidth = CGFloat(headerCharCount) * Self.monoCharWidth * 0.75 + 48
404+
var maxWidth = CGFloat(headerCharCount) * DataGridFontCache.monoCharWidth * 0.75 + 48
436405

437406
let totalRows = rowProvider.totalRowCount
438407
let columnCount = rowProvider.columns.count
439408
// Reduce sample count for wide tables to keep total work bounded
440409
let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount
441410
let step = max(1, totalRows / effectiveSampleCount)
442-
let charWidth = Self.monoCharWidth
411+
let charWidth = DataGridFontCache.monoCharWidth
443412

444413
for i in stride(from: 0, to: totalRows, by: step) {
445414
guard let value = rowProvider.value(atRow: i, column: columnIndex) else { continue }

TablePro/Views/Results/DataGridView.swift

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
666666

667667
// Settings observer for real-time updates
668668
fileprivate var settingsObserver: NSObjectProtocol?
669+
/// Snapshot of last-seen data grid settings for change detection
670+
private var lastDataGridSettings: DataGridSettings
669671

670672
@Binding var selectedRowIndices: Set<Int>
671673

@@ -716,6 +718,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
716718
self.onPasteRows = onPasteRows
717719
self.onUndo = onUndo
718720
self.onRedo = onRedo
721+
self.lastDataGridSettings = AppSettingsManager.shared.dataGrid
719722
super.init()
720723
updateCache()
721724

@@ -729,14 +732,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
729732

730733
DispatchQueue.main.async { [weak self] in
731734
guard let self, let tableView = self.tableView else { return }
732-
let newRowHeight = CGFloat(AppSettingsManager.shared.dataGrid.rowHeight.rawValue)
735+
let settings = AppSettingsManager.shared.dataGrid
736+
let prev = self.lastDataGridSettings
737+
self.lastDataGridSettings = settings
733738

734-
// Only reload if row height changed (requires full reload)
739+
let newRowHeight = CGFloat(settings.rowHeight.rawValue)
735740
if tableView.rowHeight != newRowHeight {
736741
tableView.rowHeight = newRowHeight
737742
tableView.tile()
738-
} else {
739-
// For other settings (date format, NULL display), just reload visible rows
743+
}
744+
745+
// Font-only change: update fonts in-place without reloadData
746+
// to avoid recycling cells through the reuse pool outside the
747+
// normal SwiftUI update cycle, which can cause stale data.
748+
let fontChanged = prev.fontFamily != settings.fontFamily || prev.fontSize != settings.fontSize
749+
let dataChanged = prev.dateFormat != settings.dateFormat
750+
|| prev.nullDisplay != settings.nullDisplay
751+
752+
if fontChanged {
753+
Self.updateVisibleCellFonts(tableView: tableView)
754+
}
755+
756+
if dataChanged {
740757
let visibleRect = tableView.visibleRect
741758
let visibleRange = tableView.rows(in: visibleRect)
742759
if visibleRange.length > 0 {
@@ -761,6 +778,37 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
761778
cachedColumnCount = rowProvider.columns.count
762779
}
763780

781+
// MARK: - Font Updates
782+
783+
/// Update fonts on existing visible cell views in-place.
784+
/// Uses `DataGridFontVariant` tags set during cell configuration
785+
/// to apply the correct font variant without inspecting cell content.
786+
@MainActor
787+
static func updateVisibleCellFonts(tableView: NSTableView) {
788+
let visibleRect = tableView.visibleRect
789+
let visibleRange = tableView.rows(in: visibleRect)
790+
guard visibleRange.length > 0 else { return }
791+
792+
let columnCount = tableView.numberOfColumns
793+
for row in visibleRange.location..<(visibleRange.location + visibleRange.length) {
794+
for col in 0..<columnCount {
795+
guard let cellView = tableView.view(atColumn: col, row: row, makeIfNecessary: false) as? NSTableCellView,
796+
let textField = cellView.textField else { continue }
797+
798+
switch textField.tag {
799+
case DataGridFontVariant.rowNumber:
800+
textField.font = DataGridFontCache.rowNumber
801+
case DataGridFontVariant.italic:
802+
textField.font = DataGridFontCache.italic
803+
case DataGridFontVariant.medium:
804+
textField.font = DataGridFontCache.medium
805+
default:
806+
textField.font = DataGridFontCache.regular
807+
}
808+
}
809+
}
810+
}
811+
764812
// MARK: - Row Visual State Cache
765813

766814
@MainActor

0 commit comments

Comments
 (0)