diff --git a/CHANGELOG.md b/CHANGELOG.md index e316799c9..223d7e9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL import options (wrap in transaction, disable FK checks) now persist across launches - `needsRestart` banner persists across app quit/relaunch after plugin uninstall - Copy as INSERT/UPDATE SQL statements from data grid context menu +- Configurable font family and size for data grid (Settings > Data Grid > Font) - Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour - MSSQL query cancellation (`cancelQuery`) and lock timeout (`applyQueryTimeout`) support - `~/.pgpass` file support for PostgreSQL/Redshift connections with live validation in the connection form diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 1abd3254a..9966e2c86 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -60,6 +60,7 @@ final class AppSettingsManager { storage.saveDataGrid(validated) // Update date formatting service with new format DateFormattingService.shared.updateFormat(validated.dateFormat) + DataGridFontCache.reloadFromSettings(validated) notifyChange(.dataGridSettingsDidChange) } } @@ -135,6 +136,8 @@ final class AppSettingsManager { // Initialize DateFormattingService with current format DateFormattingService.shared.updateFormat(dataGrid.dateFormat) + DataGridFontCache.reloadFromSettings(dataGrid) + // Observe system accessibility text size changes and re-apply editor fonts observeAccessibilityTextSizeChanges() } @@ -169,6 +172,8 @@ final class AppSettingsManager { Self.logger.debug("Accessibility text size changed, scale: \(newScale, format: .fixed(precision: 2))") // Re-apply editor fonts with the updated accessibility scale factor SQLEditorTheme.reloadFromSettings(editor) + DataGridFontCache.reloadFromSettings(dataGrid) + notifyChange(.dataGridSettingsDidChange) // Notify the editor view to rebuild its configuration NotificationCenter.default.post(name: .accessibilityTextSizeDidChange, object: self) } diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 0ec059ad7..e47a85eb1 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -354,6 +354,8 @@ enum DateFormatOption: String, Codable, CaseIterable, Identifiable { /// Data grid settings struct DataGridSettings: Codable, Equatable { + var fontFamily: EditorFont + var fontSize: Int var rowHeight: DataGridRowHeight var dateFormat: DateFormatOption var nullDisplay: String @@ -364,6 +366,8 @@ struct DataGridSettings: Codable, Equatable { static let `default` = DataGridSettings() init( + fontFamily: EditorFont = .systemMono, + fontSize: Int = 13, rowHeight: DataGridRowHeight = .normal, dateFormat: DateFormatOption = .iso8601, nullDisplay: String = "NULL", @@ -371,6 +375,8 @@ struct DataGridSettings: Codable, Equatable { showAlternateRows: Bool = true, autoShowInspector: Bool = false ) { + self.fontFamily = fontFamily + self.fontSize = fontSize self.rowHeight = rowHeight self.dateFormat = dateFormat self.nullDisplay = nullDisplay @@ -381,6 +387,8 @@ struct DataGridSettings: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + fontFamily = try container.decodeIfPresent(EditorFont.self, forKey: .fontFamily) ?? .systemMono + fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 13 rowHeight = try container.decode(DataGridRowHeight.self, forKey: .rowHeight) dateFormat = try container.decode(DateFormatOption.self, forKey: .dateFormat) nullDisplay = try container.decode(String.self, forKey: .nullDisplay) @@ -389,6 +397,11 @@ struct DataGridSettings: Codable, Equatable { autoShowInspector = try container.decodeIfPresent(Bool.self, forKey: .autoShowInspector) ?? false } + /// Clamped font size (10-18) + var clampedFontSize: Int { + min(max(fontSize, 10), 18) + } + // MARK: - Validated Properties /// Validated and sanitized nullDisplay (max 20 chars, no newlines) diff --git a/TablePro/Theme/DataGridFontCache.swift b/TablePro/Theme/DataGridFontCache.swift new file mode 100644 index 000000000..06f2d1fc6 --- /dev/null +++ b/TablePro/Theme/DataGridFontCache.swift @@ -0,0 +1,45 @@ +// +// DataGridFontCache.swift +// TablePro +// +// Cached font variants for the data grid. +// Updated via reloadFromSettings() when user changes font preferences. +// + +import AppKit + +/// Tags stored on NSTextField.tag to identify which font variant a cell uses. +/// Used by `updateVisibleCellFonts` to re-apply the correct variant after a font change. +enum DataGridFontVariant { + static let regular = 0 + static let italic = 1 + static let medium = 2 + static let rowNumber = 3 +} + +@MainActor +struct DataGridFontCache { + private(set) static var regular = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + private(set) static var italic = regular.withTraits(.italic) + private(set) static var medium = NSFont.monospacedSystemFont(ofSize: 13, weight: .medium) + private(set) static var rowNumber = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) + private(set) static var measureFont = regular + private(set) static var monoCharWidth: CGFloat = { + let attrs: [NSAttributedString.Key: Any] = [.font: regular] + return ("M" as NSString).size(withAttributes: attrs).width + }() + + @MainActor + static func reloadFromSettings(_ settings: DataGridSettings) { + let scale = SQLEditorTheme.accessibilityScaleFactor + let scaledSize = round(CGFloat(settings.clampedFontSize) * scale) + regular = settings.fontFamily.font(size: scaledSize) + italic = regular.withTraits(.italic) + medium = NSFontManager.shared.convert(regular, toHaveTrait: .boldFontMask) + let rowNumSize = max(round(scaledSize - 1), 9) + rowNumber = NSFont.monospacedDigitSystemFont(ofSize: rowNumSize, weight: .regular) + measureFont = regular + let attrs: [NSAttributedString.Key: Any] = [.font: regular] + monoCharWidth = ("M" as NSString).size(withAttributes: attrs).width + } +} diff --git a/TablePro/Views/Results/CellOverlayEditor.swift b/TablePro/Views/Results/CellOverlayEditor.swift index 818f30336..85ba973e4 100644 --- a/TablePro/Views/Results/CellOverlayEditor.swift +++ b/TablePro/Views/Results/CellOverlayEditor.swift @@ -56,7 +56,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { let cellRect = cellView.convert(cellView.bounds, to: tableView) // Determine overlay height — at least the cell height, up to 120pt - let lineHeight: CGFloat = CellOverlayFonts.regular.boundingRectForFont.height + 4 + let lineHeight: CGFloat = DataGridFontCache.regular.boundingRectForFont.height + 4 let lineCount = CGFloat(value.components(separatedBy: .newlines).count) let contentHeight = max(lineCount * lineHeight + 8, cellRect.height) let overlayHeight = min(contentHeight, 120) @@ -73,7 +73,7 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { textView.overlayEditor = self textView.isRichText = false textView.allowsUndo = true - textView.font = CellOverlayFonts.regular + textView.font = DataGridFontCache.regular textView.textColor = .labelColor textView.backgroundColor = .textBackgroundColor textView.isVerticallyResizable = true @@ -216,15 +216,6 @@ final class CellOverlayEditor: NSObject, NSTextViewDelegate { // Up/Down arrows — let NSTextView handle natively for line navigation return false } - - // MARK: - Fonts - - private enum CellOverlayFonts { - static let regular = NSFont.monospacedSystemFont( - ofSize: DesignConstants.FontSize.body, - weight: .regular - ) - } } // MARK: - Overlay Text View diff --git a/TablePro/Views/Results/DataGridCellFactory.swift b/TablePro/Views/Results/DataGridCellFactory.swift index cee0ad0bc..f54d12ad7 100644 --- a/TablePro/Views/Results/DataGridCellFactory.swift +++ b/TablePro/Views/Results/DataGridCellFactory.swift @@ -70,24 +70,6 @@ final class DataGridCellFactory { } } - // MARK: - Cached Fonts (avoid recreation per cell render) - - private enum CellFonts { - static let regular = NSFont.monospacedSystemFont( - ofSize: DesignConstants.FontSize.body, - weight: .regular - ) - static let italic = regular.withTraits(.italic) - static let medium = NSFont.monospacedSystemFont( - ofSize: DesignConstants.FontSize.body, - weight: .medium - ) - static let rowNumber = NSFont.monospacedDigitSystemFont( - ofSize: DesignConstants.FontSize.medium, - weight: .regular - ) - } - // MARK: - Cached Colors (avoid allocation per cell render) private enum CellColors { @@ -114,13 +96,15 @@ final class DataGridCellFactory { let textField = reused.textField { cellView = reused cell = textField + cell.font = DataGridFontCache.rowNumber } else { cellView = NSTableCellView() cellView.identifier = cellViewId cell = NSTextField(labelWithString: "") cell.alignment = .right - cell.font = CellFonts.rowNumber + cell.font = DataGridFontCache.rowNumber + cell.tag = DataGridFontVariant.rowNumber cell.textColor = .secondaryLabelColor cell.translatesAutoresizingMaskIntoConstraints = false @@ -194,7 +178,7 @@ final class DataGridCellFactory { cellView.canDrawSubviewsIntoLayer = true cell = CellTextField() - cell.font = CellFonts.regular + cell.font = DataGridFontCache.regular cell.drawsBackground = false cell.isBordered = false cell.focusRingType = .none @@ -330,35 +314,28 @@ final class DataGridCellFactory { if value == nil { cell.stringValue = "" + cell.font = DataGridFontCache.italic + cell.tag = DataGridFontVariant.italic if !isLargeDataset { cell.placeholderString = nullDisplayString - cell.textColor = .secondaryLabelColor - if cell.font !== CellFonts.italic { - cell.font = CellFonts.italic - } - } else { - cell.textColor = .secondaryLabelColor } + cell.textColor = .secondaryLabelColor } else if value == "__DEFAULT__" { cell.stringValue = "" + cell.font = DataGridFontCache.medium + cell.tag = DataGridFontVariant.medium if !isLargeDataset { cell.placeholderString = "DEFAULT" - cell.textColor = .systemBlue - cell.font = CellFonts.medium - } else { - cell.textColor = .systemBlue } + cell.textColor = .systemBlue } else if value == "" { cell.stringValue = "" + cell.font = DataGridFontCache.italic + cell.tag = DataGridFontVariant.italic if !isLargeDataset { cell.placeholderString = "Empty" - cell.textColor = .secondaryLabelColor - if cell.font !== CellFonts.italic { - cell.font = CellFonts.italic - } - } else { - cell.textColor = .secondaryLabelColor } + cell.textColor = .secondaryLabelColor } else { var displayValue = value ?? "" @@ -378,9 +355,8 @@ final class DataGridCellFactory { cell.stringValue = displayValue (cell as? CellTextField)?.originalValue = value cell.textColor = .labelColor - if cell.font !== CellFonts.regular { - cell.font = CellFonts.regular - } + cell.font = DataGridFontCache.regular + cell.tag = DataGridFontVariant.regular } } @@ -394,13 +370,6 @@ final class DataGridCellFactory { private static let sampleRowCount = 30 /// Maximum characters to consider per cell for width estimation private static let maxMeasureChars = 50 - /// Font for measuring cell content (monospaced — all glyphs have equal advance) - private static let measureFont = NSFont.monospacedSystemFont(ofSize: DesignConstants.FontSize.body, weight: .regular) - /// Pre-computed advance width of a single monospaced glyph (avoids per-row CoreText calls) - private static let monoCharWidth: CGFloat = { - let attrs: [NSAttributedString.Key: Any] = [.font: measureFont] - return ("M" as NSString).size(withAttributes: attrs).width - }() /// Font for measuring header private static let headerFont = NSFont.systemFont(ofSize: DesignConstants.FontSize.body, weight: .semibold) @@ -432,14 +401,14 @@ final class DataGridCellFactory { // instead of CoreText measurement. ~0.6 of mono width is a good estimate // for proportional system font. let headerCharCount = (columnName as NSString).length - var maxWidth = CGFloat(headerCharCount) * Self.monoCharWidth * 0.75 + 48 + var maxWidth = CGFloat(headerCharCount) * DataGridFontCache.monoCharWidth * 0.75 + 48 let totalRows = rowProvider.totalRowCount let columnCount = rowProvider.columns.count // Reduce sample count for wide tables to keep total work bounded let effectiveSampleCount = columnCount > 50 ? 10 : Self.sampleRowCount let step = max(1, totalRows / effectiveSampleCount) - let charWidth = Self.monoCharWidth + let charWidth = DataGridFontCache.monoCharWidth for i in stride(from: 0, to: totalRows, by: step) { guard let value = rowProvider.value(atRow: i, column: columnIndex) else { continue } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 524939b15..f6bdc4410 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -666,6 +666,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // Settings observer for real-time updates fileprivate var settingsObserver: NSObjectProtocol? + /// Snapshot of last-seen data grid settings for change detection + private var lastDataGridSettings: DataGridSettings @Binding var selectedRowIndices: Set @@ -716,6 +718,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData self.onPasteRows = onPasteRows self.onUndo = onUndo self.onRedo = onRedo + self.lastDataGridSettings = AppSettingsManager.shared.dataGrid super.init() updateCache() @@ -729,14 +732,28 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData DispatchQueue.main.async { [weak self] in guard let self, let tableView = self.tableView else { return } - let newRowHeight = CGFloat(AppSettingsManager.shared.dataGrid.rowHeight.rawValue) + let settings = AppSettingsManager.shared.dataGrid + let prev = self.lastDataGridSettings + self.lastDataGridSettings = settings - // Only reload if row height changed (requires full reload) + let newRowHeight = CGFloat(settings.rowHeight.rawValue) if tableView.rowHeight != newRowHeight { tableView.rowHeight = newRowHeight tableView.tile() - } else { - // For other settings (date format, NULL display), just reload visible rows + } + + // Font-only change: update fonts in-place without reloadData + // to avoid recycling cells through the reuse pool outside the + // normal SwiftUI update cycle, which can cause stale data. + let fontChanged = prev.fontFamily != settings.fontFamily || prev.fontSize != settings.fontSize + let dataChanged = prev.dateFormat != settings.dateFormat + || prev.nullDisplay != settings.nullDisplay + + if fontChanged { + Self.updateVisibleCellFonts(tableView: tableView) + } + + if dataChanged { let visibleRect = tableView.visibleRect let visibleRange = tableView.rows(in: visibleRect) if visibleRange.length > 0 { @@ -761,6 +778,37 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData cachedColumnCount = rowProvider.columns.count } + // MARK: - Font Updates + + /// Update fonts on existing visible cell views in-place. + /// Uses `DataGridFontVariant` tags set during cell configuration + /// to apply the correct font variant without inspecting cell content. + @MainActor + static func updateVisibleCellFonts(tableView: NSTableView) { + let visibleRect = tableView.visibleRect + let visibleRange = tableView.rows(in: visibleRect) + guard visibleRange.length > 0 else { return } + + let columnCount = tableView.numberOfColumns + for row in visibleRange.location..<(visibleRange.location + visibleRange.length) { + for col in 0.. 0) + #expect(DataGridFontCache.italic.pointSize > 0) + #expect(DataGridFontCache.medium.pointSize > 0) + #expect(DataGridFontCache.rowNumber.pointSize > 0) + #expect(DataGridFontCache.monoCharWidth > 0) + } + + @MainActor + @Test("fonts update when reloadFromSettings called with different settings") + func fontsUpdateOnReload() { + DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .systemMono, fontSize: 13)) + let initialSize = DataGridFontCache.regular.pointSize + let initialCharWidth = DataGridFontCache.monoCharWidth + + DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .systemMono, fontSize: 18)) + #expect(DataGridFontCache.regular.pointSize > initialSize) + #expect(DataGridFontCache.monoCharWidth >= initialCharWidth) + } + + @MainActor + @Test("different font families produce different fonts") + func differentFamilies() { + DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .systemMono, fontSize: 13)) + let systemMonoName = DataGridFontCache.regular.fontName + + DataGridFontCache.reloadFromSettings(DataGridSettings(fontFamily: .menlo, fontSize: 13)) + let menloName = DataGridFontCache.regular.fontName + + #expect(systemMonoName != menloName) + } } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 820709c5e..e8f5fdefe 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -147,6 +147,19 @@ Fully anonymous: no personal information, queries, or database content is transm ## Data Grid Settings +### Font + +| Setting | Default | Range | Description | +|---------|---------|-------|-------------| +| **Font family** | System Mono | System Mono, SF Mono, Menlo, Monaco, Courier New | Monospace font for data grid cells | +| **Font size** | 13 pt | 10-18 pt | Text size in data grid cells | + +The font setting affects all data grid cells, including NULL placeholders and cell editing overlays. A live preview is shown in the settings panel. + + +The data grid font is independent of the SQL editor font. You can use different fonts and sizes for each. + + ### Row Height | Option | Height | Best For | @@ -502,6 +515,8 @@ See [Editor Settings](/customization/editor-settings) for details. | Accent color | Appearance | UI accent color | | Row height | Data Grid | Grid row height | | Date format | Data Grid | Date display format | +| Font family | Data Grid | Monospace font for data cells | +| Font size | Data Grid | Data cell text size (10-18 pt) | See [Appearance](/customization/appearance) for details. diff --git a/docs/vi/customization/settings.mdx b/docs/vi/customization/settings.mdx index a141e14ed..a4d589ba2 100644 --- a/docs/vi/customization/settings.mdx +++ b/docs/vi/customization/settings.mdx @@ -145,6 +145,19 @@ Hoàn toàn ẩn danh: không gửi thông tin cá nhân, truy vấn hay nội d ## Cài đặt Bảng Dữ liệu +### Phông chữ + +| Cài đặt | Mặc định | Phạm vi | Mô tả | +|---------|---------|-------|-------------| +| **Phông chữ** | System Mono | System Mono, SF Mono, Menlo, Monaco, Courier New | Phông chữ đơn cách cho ô bảng dữ liệu | +| **Cỡ chữ** | 13 pt | 10-18 pt | Cỡ chữ trong ô bảng dữ liệu | + +Cài đặt phông chữ ảnh hưởng đến tất cả ô bảng dữ liệu, bao gồm placeholder NULL và overlay chỉnh sửa ô. Bản xem trước trực tiếp được hiển thị trong bảng cài đặt. + + +Phông chữ bảng dữ liệu độc lập với phông chữ SQL editor. Bạn có thể dùng phông chữ và cỡ chữ khác nhau cho mỗi loại. + + ### Chiều cao Hàng | Tùy chọn | Chiều cao | Phù hợp | @@ -496,6 +509,8 @@ Xem [Cài đặt Editor](/vi/customization/editor-settings). | Màu nhấn | Appearance | Màu nhấn UI | | Chiều cao hàng | Data Grid | Chiều cao hàng bảng | | Định dạng ngày | Data Grid | Định dạng hiển thị ngày | +| Phông chữ | Data Grid | Phông chữ đơn cách cho ô dữ liệu | +| Cỡ chữ | Data Grid | Cỡ chữ ô dữ liệu (10-18 pt) | Xem [Giao diện](/vi/customization/appearance).