diff --git a/apple/TablePro/Core/Menu/ZoomCommandResponding.swift b/apple/TablePro/Core/Menu/ZoomCommandResponding.swift index ed337ccabf..7786905d21 100644 --- a/apple/TablePro/Core/Menu/ZoomCommandResponding.swift +++ b/apple/TablePro/Core/Menu/ZoomCommandResponding.swift @@ -5,12 +5,51 @@ import AppKit -/// View > Zoom In and Zoom Out, named once so the menu builder needs no class. The responder chain -/// decides what zooming means: a focused diagram zooms itself, and the window falls back to the -/// editor's text size, the way Xcode gives Command-Plus to whichever pane has focus. One item per -/// verb, because two items sharing Command-= resolve by menu order rather than by focus. +// View > Zoom In and Zoom Out go to whatever is nearest the focus: a diagram's scroll view, a data +// grid's scroll view, then the window, which zooms the editor. One item per verb, because two items +// sharing Command-= resolve by menu order rather than by focus. @MainActor @objc protocol ZoomCommandResponding { func zoomIn(_ sender: Any?) func zoomOut(_ sender: Any?) } + +// A pane whose zoom is its font size in Settings, so a zoom is the same edit the size picker makes. +enum FontSizeZoom { + case editor + case dataGrid + + // Nil for any other command. Read live: the size is the app's setting, not one window's. + func enablement(_ action: Selector, typography: TypographySettings) -> Bool? { + switch action { + case #selector(ZoomCommandResponding.zoomIn(_:)): + return size(in: typography, zoomedBy: 1) != nil + case #selector(ZoomCommandResponding.zoomOut(_:)): + return size(in: typography, zoomedBy: -1) != nil + default: + return nil + } + } + + @MainActor + func zoom(by delta: Int, settings: AppSettingsManager = .shared) { + var typography = settings.typography + guard let size = size(in: typography, zoomedBy: delta) else { return } + switch self { + case .editor: + typography.editorFontSize = size + case .dataGrid: + typography.dataGridFontSize = size + } + settings.typography = typography + } + + private func size(in typography: TypographySettings, zoomedBy delta: Int) -> Int? { + switch self { + case .editor: + return typography.editorFontSize(zoomedBy: delta) + case .dataGrid: + return typography.dataGridFontSize(zoomedBy: delta) + } + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index c1cf1872bc..98e7d2f74d 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -751,7 +751,7 @@ extension MainSplitViewController: NSMenuItemValidation { if action == #selector(setSafeModeLevel(_:)) { return canChooseSafeModeLevel(menuItem) } if action == #selector(requestDisconnect) { return canDisconnect } if action == #selector(retryConnection) { return canReconnect } - if let zoom = Self.zoomEnablement(action, typography: AppSettingsManager.shared.typography) { + if let zoom = FontSizeZoom.editor.enablement(action, typography: AppSettingsManager.shared.typography) { return zoom } return Self.isEnabled(action, context: menuValidationContext(naming: menuItem)) diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift index b42bee1920..dfd8dd6a96 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift @@ -144,29 +144,14 @@ extension MainSplitViewController { } } -/// The window's answer when nothing nearer the focus zooms: a focused diagram's scroll view claims -/// these first, so this is what Zoom In means everywhere else. +// The window's answer when nothing nearer the focus zooms: a focused diagram or data grid claims these +// first, so this is what Zoom In means everywhere else. extension MainSplitViewController: ZoomCommandResponding { @objc func zoomIn(_ sender: Any?) { - adjustEditorTextSize(by: 1) + FontSizeZoom.editor.zoom(by: 1) } @objc func zoomOut(_ sender: Any?) { - adjustEditorTextSize(by: -1) - } - - /// Nil for any other command. The size is the app's setting, not this window's, so it is read - /// live instead of through the captured validation context. - static func zoomEnablement(_ action: Selector, typography: TypographySettings) -> Bool? { - if action == #selector(zoomIn(_:)) { return typography.editorFontSize(zoomedBy: 1) != nil } - if action == #selector(zoomOut(_:)) { return typography.editorFontSize(zoomedBy: -1) != nil } - return nil - } - - private func adjustEditorTextSize(by delta: Int) { - var typography = AppSettingsManager.shared.typography - guard let size = typography.editorFontSize(zoomedBy: delta) else { return } - typography.editorFontSize = size - AppSettingsManager.shared.typography = typography + FontSizeZoom.editor.zoom(by: -1) } } diff --git a/apple/TablePro/Models/Settings/TypographySettings.swift b/apple/TablePro/Models/Settings/TypographySettings.swift index be99e8cd9a..df0a547310 100644 --- a/apple/TablePro/Models/Settings/TypographySettings.swift +++ b/apple/TablePro/Models/Settings/TypographySettings.swift @@ -51,10 +51,17 @@ internal struct TypographySettings: Codable, Equatable, Sendable { min(max(size, sizeRange.lowerBound), sizeRange.upperBound) } - /// Nil at either end of the range, which is what dims Zoom In and Zoom Out there. + // Nil at either end of the range, which is what dims Zoom In and Zoom Out there. internal func editorFontSize(zoomedBy delta: Int) -> Int? { - let current = clampedEditorFontSize - let zoomed = Self.clamp(current + delta) + Self.size(clampedEditorFontSize, zoomedBy: delta) + } + + internal func dataGridFontSize(zoomedBy delta: Int) -> Int? { + Self.size(clampedDataGridFontSize, zoomedBy: delta) + } + + private static func size(_ current: Int, zoomedBy delta: Int) -> Int? { + let zoomed = clamp(current + delta) return zoomed == current ? nil : zoomed } } diff --git a/apple/TablePro/Views/Results/CellOverlayBase.swift b/apple/TablePro/Views/Results/CellOverlayBase.swift index 0b1e5b35b5..4eadc03e16 100644 --- a/apple/TablePro/Views/Results/CellOverlayBase.swift +++ b/apple/TablePro/Views/Results/CellOverlayBase.swift @@ -25,6 +25,7 @@ class CellOverlayBase: NSObject { private var appResignObserver: NSObjectProtocol? private var windowResignKeyObserver: NSObjectProtocol? private var outsideClickMonitor: Any? + private var isFollowingGrid = false /// Held for the life of the open overlay: its subscription is what repaints the text on a theme /// change. private var textStyle: ThemeTextViewStyle? @@ -114,6 +115,20 @@ class CellOverlayBase: NSObject { onRemove?() } + // A zoom resizes the value and moves its cell, so the overlay follows instead of ending the edit as a + // column resize or a scroll would. + func followGrid(through change: () -> Void) { + isFollowingGrid = true + defer { isFollowingGrid = false } + change() + guard let container, let hostTableView, let scrollView = hostTableView.enclosingScrollView, + let textView = (container.subviews.first as? NSScrollView)?.documentView as? NSTextView else { return } + hostTableView.scrollRowToVisible(row) + textView.font = ThemeEngine.shared.valueFont + let cellFrame = Self.displayedCellFrame(in: hostTableView, column: column, row: row) + container.frame = scrollView.convert(Self.overlayFrame(for: cellFrame, value: textView.string), from: hostTableView) + } + static func overlayFrame(for cellFrame: NSRect, value: String) -> NSRect { let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4 // A CR on its own breaks the line in the text view as LF does; CRLF is one break. @@ -205,6 +220,7 @@ class CellOverlayBase: NSObject { queue: .main ) { [weak self] _ in MainActor.assumeIsolated { + guard self?.isFollowingGrid == false else { return } self?.handleDismiss(reason: .scroll) } } @@ -216,6 +232,7 @@ class CellOverlayBase: NSObject { ].map { name in NotificationCenter.default.addObserver(forName: name, object: hostTableView, queue: .main) { [weak self] _ in MainActor.assumeIsolated { + guard self?.isFollowingGrid == false else { return } self?.handleDismiss(reason: .columnGeometry) } } diff --git a/apple/TablePro/Views/Results/Cells/DataGridMetrics.swift b/apple/TablePro/Views/Results/Cells/DataGridMetrics.swift index 32463bd50e..44a3c5a32c 100644 --- a/apple/TablePro/Views/Results/Cells/DataGridMetrics.swift +++ b/apple/TablePro/Views/Results/Cells/DataGridMetrics.swift @@ -3,7 +3,7 @@ // TablePro // -import CoreGraphics +import AppKit enum DataGridMetrics { static let cellHorizontalInset: CGFloat = 4 @@ -14,4 +14,9 @@ enum DataGridMetrics { /// Room the grid scrolls past its last column. Without it the last column's divider sits on the /// viewport's last point, which a resizable window claims for its own edge resize. static let trailingSpace: CGFloat = 40 + + // The Row height setting is a floor: a grid font taller than the row would be cut at the cell edge. + static func rowHeight(_ setting: DataGridRowHeight, font: NSFont) -> CGFloat { + max(CGFloat(setting.rawValue), (font.ascender - font.descender + font.leading).rounded(.up)) + } } diff --git a/apple/TablePro/Views/Results/DataGridCoordinator.swift b/apple/TablePro/Views/Results/DataGridCoordinator.swift index 3cfbc465db..f3bea8d605 100644 --- a/apple/TablePro/Views/Results/DataGridCoordinator.swift +++ b/apple/TablePro/Views/Results/DataGridCoordinator.swift @@ -642,13 +642,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func applyDataGridSettingsChange(from previous: DataGridSettings, to settings: DataGridSettings) { - guard let tableView else { return } - let newRowHeight = CGFloat(settings.rowHeight.rawValue) - if tableView.rowHeight != newRowHeight { - tableView.rowHeight = newRowHeight - tableView.tile() - repaintRowGutter() - } + guard tableView != nil else { return } + applyRowHeight(DataGridMetrics.rowHeight(settings.rowHeight, font: ThemeEngine.shared.dataGridFonts.regular)) let dataChanged = previous.dateFormat != settings.dateFormat || previous.nullDisplay != settings.nullDisplay @@ -661,6 +656,15 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } } + func applyRowHeight(_ rowHeight: CGFloat) { + guard let tableView, tableView.rowHeight != rowHeight else { return } + keepingFirstVisibleRow { + tableView.rowHeight = rowHeight + tableView.tile() + } + repaintRowGutter() + } + func observeThemeChanges() { themeCancellable = themeScope.changes .receive(on: RunLoop.main) @@ -678,8 +682,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData themeDidChange() } - /// `themeChanged` also fires for an Accent, Highlight or Increase Contrast change, and none of - /// them moves a value, so this repaints what is on screen and never reloads or refetches. + // Also fires for Accent, Highlight and Increase Contrast, none of which moves a value: repaint, never reload. func themeDidChange() { guard let tableView else { return } cellRegistry.preparePalette(in: tableView.effectiveAppearance) @@ -691,8 +694,19 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData rowView.needsDisplay = true (rowView as? DataGridRowView)?.redrawCells() } - // The row-number font decides the column's width, which the pinned gutter mirrors. - resizeRowNumberColumnForCurrentRange() + let resize = { + // The row-number font decides the column's width, which the pinned gutter mirrors. + self.resizeRowNumberColumnForCurrentRange() + self.applyRowHeight(DataGridMetrics.rowHeight( + AppSettingsManager.shared.dataGrid.rowHeight, + font: ThemeEngine.shared.dataGridFonts.regular + )) + } + if let openOverlay = [overlayEditor as CellOverlayBase?, overlayViewer].compactMap({ $0 }).first(where: { $0.isActive }) { + openOverlay.followGrid(through: resize) + } else { + resize() + } repaintRowGutter() selectionController.overlay?.needsDisplay = true } diff --git a/apple/TablePro/Views/Results/DataGridScrollView.swift b/apple/TablePro/Views/Results/DataGridScrollView.swift new file mode 100644 index 0000000000..fc5d2a6c67 --- /dev/null +++ b/apple/TablePro/Views/Results/DataGridScrollView.swift @@ -0,0 +1,26 @@ +// +// DataGridScrollView.swift +// TablePro +// + +import AppKit + +// Zoom lives here rather than on the table because a cell being edited sits in an overlay mounted on +// this scroll view, so the table is not in that responder chain and this view is in both. +final class DataGridScrollView: NSScrollView, ZoomCommandResponding { + @objc func zoomIn(_ sender: Any?) { + FontSizeZoom.dataGrid.zoom(by: 1) + } + + @objc func zoomOut(_ sender: Any?) { + FontSizeZoom.dataGrid.zoom(by: -1) + } +} + +extension DataGridScrollView: NSMenuItemValidation { + // At either end of the range the item dims rather than falling through to the editor's size. + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + guard let action = menuItem.action else { return false } + return FontSizeZoom.dataGrid.enablement(action, typography: AppSettingsManager.shared.typography) ?? true + } +} diff --git a/apple/TablePro/Views/Results/DataGridView.swift b/apple/TablePro/Views/Results/DataGridView.swift index 73863d8d4e..a2a912ab28 100644 --- a/apple/TablePro/Views/Results/DataGridView.swift +++ b/apple/TablePro/Views/Results/DataGridView.swift @@ -149,7 +149,7 @@ struct DataGridView: NSViewRepresentable { // MARK: - NSViewRepresentable func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() + let scrollView = DataGridScrollView() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.autohidesScrollers = true @@ -173,7 +173,7 @@ struct DataGridView: NSViewRepresentable { tableView.columnAutoresizingStyle = .noColumnAutoresizing tableView.gridStyleMask = [] tableView.intercellSpacing = NSSize(width: 1, height: 0) - tableView.rowHeight = CGFloat(settings.rowHeight.rawValue) + tableView.rowHeight = DataGridMetrics.rowHeight(settings.rowHeight, font: ThemeEngine.shared.dataGridFonts.regular) tableView.usesAutomaticRowHeights = false DataGridBodyChrome.applyThemeColors(to: tableView, scope: context.coordinator.themeScope) @@ -295,7 +295,7 @@ struct DataGridView: NSViewRepresentable { let rowDisplayCount = coordinator.valueFilteredIDs?.count ?? latestRows.count let columnCount = latestRows.columns.count let settings = AppSettingsManager.shared.dataGrid - let rowHeight = CGFloat(settings.rowHeight.rawValue) + let rowHeight = DataGridMetrics.rowHeight(settings.rowHeight, font: ThemeEngine.shared.dataGridFonts.regular) let alternatingRows = settings.showAlternateRows let columnComments = Self.effectiveColumnComments(for: latestRows) @@ -391,10 +391,7 @@ struct DataGridView: NSViewRepresentable { } } - if tableView.rowHeight != rowHeight { - tableView.rowHeight = rowHeight - coordinator.repaintRowGutter() - } + coordinator.applyRowHeight(rowHeight) if tableView.usesAlternatingRowBackgroundColors != alternatingRows { tableView.usesAlternatingRowBackgroundColors = alternatingRows coordinator.repaintRowGutter() diff --git a/apple/TablePro/Views/Results/Extensions/DataGridView+Viewport.swift b/apple/TablePro/Views/Results/Extensions/DataGridView+Viewport.swift index c698b320b1..8b332d0b8c 100644 --- a/apple/TablePro/Views/Results/Extensions/DataGridView+Viewport.swift +++ b/apple/TablePro/Views/Results/Extensions/DataGridView+Viewport.swift @@ -21,6 +21,20 @@ extension TableViewCoordinator { return GridViewportSample(firstVisibleRow: visibleRows.location, offset: max(0, offset)) } + // The clip view keeps its offset in points, so a new row height would otherwise bring other rows into view. + func keepingFirstVisibleRow(_ change: () -> Void) { + guard let tableView, let sample = viewportSample(), tableView.rowHeight > 0 else { + change() + return + } + let previousHeight = tableView.rowHeight + change() + let offset = sample.offset * tableView.rowHeight / previousHeight + let rowOrigin = tableView.rect(ofRow: sample.firstVisibleRow).minY + let horizontalOffset = tableView.enclosingScrollView?.contentView.bounds.origin.x ?? 0 + tableView.scroll(NSPoint(x: horizontalOffset, y: rowOrigin + offset - headerInset(of: tableView))) + } + func applyViewportPlacement(_ placement: GridViewportPlacement) { guard let tableView, tableView.numberOfRows > 0 else { return } let tableRows = tableRowsProvider() diff --git a/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift b/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift index 08320e0bc3..e151fff60b 100644 --- a/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift +++ b/apple/TablePro/Views/Settings/Appearance/ThemeEditorPreview.swift @@ -5,12 +5,12 @@ import TableProGrammars import TableProPluginKit import TableProTheme -/// The real SQL editor and the real data grid drawing the draft through a fixed scope, so the -/// preview cannot disagree with the app: every one of the theme's slots shows here as it shows in a -/// window, keyword weight and selection rules included. +// The real editor and grid on a fixed scope, so the preview cannot disagree with a window. internal struct ThemeEditorPreview: View { internal let scope: ThemeScope internal let appearance: ThemeAppearance + // A fixed scope publishes no changes, so a font size set elsewhere reaches the preview only as a remount. + @ObservedObject private var themeEngine = ThemeEngine.shared internal static let editorHeight: CGFloat = 150 internal static let gridHeight: CGFloat = 150 @@ -24,6 +24,7 @@ internal struct ThemeEditorPreview: View { ThemePreviewGrid(scope: scope) .frame(height: Self.gridHeight) } + .id([themeEngine.editorFonts.font, themeEngine.dataGridFonts.regular]) } .frame(height: Self.editorHeight + Self.gridHeight + 1) .accessibilityElement(children: .contain) diff --git a/apple/TableProTests/Models/Settings/EditorZoomTests.swift b/apple/TableProTests/Models/Settings/EditorZoomTests.swift deleted file mode 100644 index 8c277c6abb..0000000000 --- a/apple/TableProTests/Models/Settings/EditorZoomTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import AppKit -@testable import TablePro -import Testing - -struct EditorZoomTests { - @Test("Zoom moves the editor size one point inside the range") - func zoomSteps() { - let typography = TypographySettings(editorFontSize: 13) - #expect(typography.editorFontSize(zoomedBy: 1) == 14) - #expect(typography.editorFontSize(zoomedBy: -1) == 12) - } - - @Test("Zoom has nowhere to go at either end of the range") - func zoomStopsAtTheLimits() { - let largest = TypographySettings(editorFontSize: TypographySettings.sizeRange.upperBound) - let smallest = TypographySettings(editorFontSize: TypographySettings.sizeRange.lowerBound) - #expect(largest.editorFontSize(zoomedBy: 1) == nil) - #expect(largest.editorFontSize(zoomedBy: -1) == TypographySettings.sizeRange.upperBound - 1) - #expect(smallest.editorFontSize(zoomedBy: -1) == nil) - #expect(smallest.editorFontSize(zoomedBy: 1) == TypographySettings.sizeRange.lowerBound + 1) - } - - @Test("A stored size outside the range zooms from the size that is drawn") - func outOfRangeSizeZoomsFromTheDrawnSize() { - let typography = TypographySettings(editorFontSize: 30) - #expect(typography.editorFontSize(zoomedBy: 1) == nil) - #expect(typography.editorFontSize(zoomedBy: -1) == TypographySettings.sizeRange.upperBound - 1) - } - - @MainActor - @Test("Zoom In and Zoom Out each dim at their own end of the range") - func menuItemsFollowTheRange() { - let zoomIn = #selector(MainSplitViewController.zoomIn(_:)) - let zoomOut = #selector(MainSplitViewController.zoomOut(_:)) - - let middle = TypographySettings(editorFontSize: 13) - let largest = TypographySettings(editorFontSize: TypographySettings.sizeRange.upperBound) - let smallest = TypographySettings(editorFontSize: TypographySettings.sizeRange.lowerBound) - - #expect(MainSplitViewController.zoomEnablement(zoomIn, typography: middle) == true) - #expect(MainSplitViewController.zoomEnablement(zoomOut, typography: middle) == true) - #expect(MainSplitViewController.zoomEnablement(zoomIn, typography: largest) == false) - #expect(MainSplitViewController.zoomEnablement(zoomOut, typography: largest) == true) - #expect(MainSplitViewController.zoomEnablement(zoomIn, typography: smallest) == true) - #expect(MainSplitViewController.zoomEnablement(zoomOut, typography: smallest) == false) - - let other = #selector(MainSplitViewController.clearSelection(_:)) - #expect(MainSplitViewController.zoomEnablement(other, typography: middle) == nil) - } -} diff --git a/apple/TableProTests/Models/Settings/FontSizeZoomTests.swift b/apple/TableProTests/Models/Settings/FontSizeZoomTests.swift new file mode 100644 index 0000000000..d6b679f884 --- /dev/null +++ b/apple/TableProTests/Models/Settings/FontSizeZoomTests.swift @@ -0,0 +1,70 @@ +import AppKit +@testable import TablePro +import Testing + +struct FontSizeZoomTests { + @Test("Zoom moves the editor size one point inside the range") + func zoomSteps() { + let typography = TypographySettings(editorFontSize: 13) + #expect(typography.editorFontSize(zoomedBy: 1) == 14) + #expect(typography.editorFontSize(zoomedBy: -1) == 12) + } + + @Test("Zoom has nowhere to go at either end of the range") + func zoomStopsAtTheLimits() { + let largest = TypographySettings(editorFontSize: TypographySettings.sizeRange.upperBound) + let smallest = TypographySettings(editorFontSize: TypographySettings.sizeRange.lowerBound) + #expect(largest.editorFontSize(zoomedBy: 1) == nil) + #expect(largest.editorFontSize(zoomedBy: -1) == TypographySettings.sizeRange.upperBound - 1) + #expect(smallest.editorFontSize(zoomedBy: -1) == nil) + #expect(smallest.editorFontSize(zoomedBy: 1) == TypographySettings.sizeRange.lowerBound + 1) + } + + @Test("A stored size outside the range zooms from the size that is drawn") + func outOfRangeSizeZoomsFromTheDrawnSize() { + let typography = TypographySettings(editorFontSize: 30) + #expect(typography.editorFontSize(zoomedBy: 1) == nil) + #expect(typography.editorFontSize(zoomedBy: -1) == TypographySettings.sizeRange.upperBound - 1) + } + + @Test("The data grid size steps over the same range, apart from the editor's") + func dataGridSizeSteps() { + let typography = TypographySettings(editorFontSize: 13, dataGridFontSize: 16) + #expect(typography.dataGridFontSize(zoomedBy: 1) == 17) + #expect(typography.dataGridFontSize(zoomedBy: -1) == 15) + + let largest = TypographySettings(editorFontSize: 13, dataGridFontSize: TypographySettings.sizeRange.upperBound) + let smallest = TypographySettings(editorFontSize: 13, dataGridFontSize: TypographySettings.sizeRange.lowerBound) + #expect(largest.dataGridFontSize(zoomedBy: 1) == nil) + #expect(smallest.dataGridFontSize(zoomedBy: -1) == nil) + #expect(largest.editorFontSize(zoomedBy: 1) == 14) + + let outOfRange = TypographySettings(dataGridFontSize: 30) + #expect(outOfRange.dataGridFontSize(zoomedBy: -1) == TypographySettings.sizeRange.upperBound - 1) + } + + @Test("Zoom In and Zoom Out each dim at their own end of each font's range") + func menuItemsFollowTheRange() { + let zoomIn = #selector(ZoomCommandResponding.zoomIn(_:)) + let zoomOut = #selector(ZoomCommandResponding.zoomOut(_:)) + let upper = TypographySettings.sizeRange.upperBound + let lower = TypographySettings.sizeRange.lowerBound + + let middle = TypographySettings(editorFontSize: 13, dataGridFontSize: 13) + let largeEditor = TypographySettings(editorFontSize: upper, dataGridFontSize: lower) + + #expect(FontSizeZoom.editor.enablement(zoomIn, typography: middle) == true) + #expect(FontSizeZoom.editor.enablement(zoomOut, typography: middle) == true) + #expect(FontSizeZoom.editor.enablement(zoomIn, typography: largeEditor) == false) + #expect(FontSizeZoom.editor.enablement(zoomOut, typography: largeEditor) == true) + + #expect(FontSizeZoom.dataGrid.enablement(zoomIn, typography: middle) == true) + #expect(FontSizeZoom.dataGrid.enablement(zoomOut, typography: middle) == true) + #expect(FontSizeZoom.dataGrid.enablement(zoomIn, typography: largeEditor) == true) + #expect(FontSizeZoom.dataGrid.enablement(zoomOut, typography: largeEditor) == false) + + let other = #selector(MainSplitViewController.clearSelection(_:)) + #expect(FontSizeZoom.editor.enablement(other, typography: middle) == nil) + #expect(FontSizeZoom.dataGrid.enablement(other, typography: middle) == nil) + } +} diff --git a/apple/TableProTests/Views/Results/DataGridZoomCommandTests.swift b/apple/TableProTests/Views/Results/DataGridZoomCommandTests.swift new file mode 100644 index 0000000000..019d4cc92c --- /dev/null +++ b/apple/TableProTests/Views/Results/DataGridZoomCommandTests.swift @@ -0,0 +1,292 @@ +// +// DataGridZoomCommandTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +private final class ZoomLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +// Stands in for the connection window, whose controller zooms the editor when nothing nearer does. +@MainActor +private final class WindowZoomFallback: NSViewController, ZoomCommandResponding { + @objc func zoomIn(_ sender: Any?) {} + @objc func zoomOut(_ sender: Any?) {} +} + +@MainActor +private final class MountedGrid { + let window: NSWindow + private let host: NSHostingView + + init(rowCount: Int = 2) async { + let rows = TableRows.from( + queryRows: (0 ..< rowCount).map { [PluginCellValue.text("value \($0)")] }, + columns: ["name"], + columnTypes: [ColumnType.text(rawType: "TEXT")] + ) + host = NSHostingView(rootView: DataGridView( + tableRowsProvider: { rows }, + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + configuration: DataGridConfiguration(tabType: .query), + layoutPersister: ZoomLayoutPersister(), + selectedRowIndices: .constant([]), + sortState: .constant(SortState()), + columnLayout: .constant(ColumnLayoutState()) + )) + let fallback = WindowZoomFallback() + fallback.view = host + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentViewController = fallback + // A content view controller sizes the window to its view's fitting size, which for a grid is tiny. + window.setContentSize(NSSize(width: 600, height: 300)) + await settle() + } + + var tableView: KeyHandlingTableView? { + Self.first(KeyHandlingTableView.self, in: host) + } + + var scrollView: DataGridScrollView? { + tableView?.enclosingScrollView as? DataGridScrollView + } + + func close() { + window.orderOut(nil) + } + + func scroll(toRow row: Int) { + guard let tableView else { return } + let inset = tableView.enclosingScrollView?.contentView.contentInsets.top ?? 0 + tableView.scroll(NSPoint(x: 0, y: tableView.rect(ofRow: row).minY - inset)) + } + + // SwiftUI applies a change on a later run-loop turn, so each pass lays out and waits for one. + private func settle() async { + for _ in 0 ..< 10 { + host.layoutSubtreeIfNeeded() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + } + + private static func first(_ type: View.Type, in view: NSView) -> View? { + if let match = view as? View { return match } + for subview in view.subviews { + if let found = first(type, in: subview) { return found } + } + return nil + } +} + +@MainActor +private func zoomResponder(from responder: NSResponder?) -> NSResponder? { + var responder = responder + while let current = responder, !current.responds(to: #selector(ZoomCommandResponding.zoomIn(_:))) { + responder = current.nextResponder + } + return responder +} + +@MainActor +private func item(_ action: Selector) -> NSMenuItem { + NSMenuItem(title: "", action: action, keyEquivalent: "") +} + +extension GlobalStateSuites { + @Suite(.appSettings { + $0.typography = TypographySettings(editorFontSize: 13, dataGridFontSize: 13) + $0.dataGrid.rowHeight = .normal + }) + @MainActor + struct DataGridZoomCommandTests { + @Test("From a focused grid the command reaches the grid's scroll view before the window") + func focusedGridClaimsTheCommand() async throws { + let grid = await MountedGrid() + defer { grid.close() } + let tableView = try #require(grid.tableView) + let scrollView = try #require(grid.scrollView) + + #expect(grid.window.makeFirstResponder(tableView)) + + #expect(zoomResponder(from: grid.window.firstResponder) === scrollView) + } + + @Test("From a cell being edited the command reaches the grid, not the window") + func cellEditorClaimsTheCommand() async throws { + let grid = await MountedGrid() + defer { grid.close() } + let tableView = try #require(grid.tableView) + let scrollView = try #require(grid.scrollView) + let coordinator = try #require(tableView.coordinator) + let column = try #require(coordinator.tableColumnIndex(forDisplayPosition: 0)) + + coordinator.showOverlayEditor(tableView: tableView, row: 0, column: column, columnIndex: 0, value: "value") + defer { coordinator.overlayEditor?.dismiss(commit: false) } + + #expect(grid.window.firstResponder is NSTextView) + #expect(zoomResponder(from: grid.window.firstResponder) === scrollView) + } + + @Test("Zoom In and Zoom Out change the Data Grid font and leave the editor font") + func zoomChangesTheGridFontOnly() { + let scrollView = DataGridScrollView() + + scrollView.zoomIn(nil) + #expect(AppSettingsManager.shared.typography.dataGridFontSize == 14) + #expect(AppSettingsManager.shared.typography.editorFontSize == 13) + + scrollView.zoomOut(nil) + scrollView.zoomOut(nil) + #expect(AppSettingsManager.shared.typography.dataGridFontSize == 12) + #expect(AppSettingsManager.shared.typography.editorFontSize == 13) + } + + @Test( + "Zoom In dims at the largest size instead of falling through to the editor", + .appSettings { $0.typography.dataGridFontSize = TypographySettings.sizeRange.upperBound } + ) + func zoomInDimsAtTheLargestSize() { + let scrollView = DataGridScrollView() + + #expect(!scrollView.validateMenuItem(item(#selector(ZoomCommandResponding.zoomIn(_:))))) + #expect(scrollView.validateMenuItem(item(#selector(ZoomCommandResponding.zoomOut(_:))))) + + scrollView.zoomIn(nil) + #expect(AppSettingsManager.shared.typography.dataGridFontSize == TypographySettings.sizeRange.upperBound) + #expect(AppSettingsManager.shared.typography.editorFontSize == 13) + } + + @Test( + "Zoom Out dims at the smallest size", + .appSettings { $0.typography.dataGridFontSize = TypographySettings.sizeRange.lowerBound } + ) + func zoomOutDimsAtTheSmallestSize() { + let scrollView = DataGridScrollView() + + #expect(scrollView.validateMenuItem(item(#selector(ZoomCommandResponding.zoomIn(_:))))) + #expect(!scrollView.validateMenuItem(item(#selector(ZoomCommandResponding.zoomOut(_:))))) + } + + @Test( + "A font taller than the chosen row height grows the rows of an open grid", + .appSettings { + $0.typography.dataGridFontSize = TypographySettings.sizeRange.upperBound - 1 + $0.dataGrid.rowHeight = .compact + } + ) + func rowsGrowWithTheFont() async throws { + let grid = await MountedGrid() + defer { grid.close() } + let tableView = try #require(grid.tableView) + let scrollView = try #require(grid.scrollView) + let before = tableView.rowHeight + #expect(before > CGFloat(DataGridRowHeight.compact.rawValue)) + + scrollView.zoomIn(nil) + + let font = ThemeEngine.shared.dataGridFonts.regular + try await waitUntil { tableView.rowHeight == DataGridMetrics.rowHeight(.compact, font: font) } + #expect(tableView.rowHeight > before) + #expect(tableView.rowHeight >= font.ascender - font.descender + font.leading) + } + + @Test( + "Rows that grow with a zoom keep the first visible row in place", + .appSettings { $0.typography.dataGridFontSize = 20 } + ) + func zoomKeepsTheFirstVisibleRow() async throws { + let grid = await MountedGrid(rowCount: 10_000) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let scrollView = try #require(grid.scrollView) + let coordinator = try #require(tableView.coordinator) + grid.scroll(toRow: 5_000) + let before = try #require(coordinator.viewportSample()) + try #require(before.firstVisibleRow > 4_000) + let rowHeight = tableView.rowHeight + + scrollView.zoomIn(nil) + + try await waitUntil { tableView.rowHeight != rowHeight } + let after = try #require(coordinator.viewportSample()) + #expect(after.firstVisibleRow == before.firstVisibleRow) + } + + // Enough rows that the row-number column widens with the font, and a size where the rows grow: + // both are geometry changes that otherwise end the edit. + @Test( + "An open cell editor keeps its text and focus through a zoom, at the new size and on its cell", + .appSettings { $0.typography.dataGridFontSize = 20 } + ) + func openEditorFollowsTheFont() async throws { + let grid = await MountedGrid(rowCount: 10_000) + defer { grid.close() } + let tableView = try #require(grid.tableView) + let scrollView = try #require(grid.scrollView) + let coordinator = try #require(tableView.coordinator) + let column = try #require(coordinator.tableColumnIndex(forDisplayPosition: 0)) + let rowNumbers = try #require(tableView.tableColumns.first { $0.identifier == ColumnIdentitySchema.rowNumberIdentifier }) + let rowNumberWidth = rowNumbers.width + let rowHeight = tableView.rowHeight + grid.scroll(toRow: 5_000) + let editedRow = 5_005 + try #require(NSLocationInRange(editedRow, tableView.rows(in: tableView.visibleRect))) + coordinator.showOverlayEditor(tableView: tableView, row: editedRow, column: column, columnIndex: 0, value: "value") + let editor = try #require(coordinator.overlayEditor) + defer { editor.dismiss(commit: false) } + let textView = try #require(grid.window.firstResponder as? NSTextView) + let container = try #require(editor.containerView) + textView.string = "edited" + + scrollView.zoomIn(nil) + + try await waitUntil { textView.font == ThemeEngine.shared.valueFont } + try #require(rowNumbers.width != rowNumberWidth) + try #require(tableView.rowHeight != rowHeight) + #expect(editor.isActive) + #expect(textView.string == "edited") + #expect(grid.window.firstResponder === textView) + #expect(NSLocationInRange(editedRow, tableView.rows(in: tableView.visibleRect))) + let cellFrame = CellOverlayBase.displayedCellFrame(in: tableView, column: column, row: editedRow) + let expected = scrollView.convert(CellOverlayBase.overlayFrame(for: cellFrame, value: "edited"), from: tableView) + #expect(container.frame == expected) + } + } +} + +struct DataGridRowHeightTests { + @Test("The row height setting stands while the font fits in it") + func settingStandsForAFittingFont() { + let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + #expect(DataGridMetrics.rowHeight(.normal, font: font) == CGFloat(DataGridRowHeight.normal.rawValue)) + #expect(DataGridMetrics.rowHeight(.compact, font: font) == CGFloat(DataGridRowHeight.compact.rawValue)) + } + + @Test("A font taller than the row raises the row to hold the whole line") + func tallFontRaisesTheRow() { + let font = NSFont.monospacedSystemFont(ofSize: 24, weight: .regular) + let height = DataGridMetrics.rowHeight(.normal, font: font) + #expect(height > CGFloat(DataGridRowHeight.normal.rawValue)) + #expect(height >= font.ascender - font.descender + font.leading) + #expect(height == height.rounded()) + } +} diff --git a/apple/changelog.d/3770.added.md b/apple/changelog.d/3770.added.md new file mode 100644 index 0000000000..b8fcb5cf66 --- /dev/null +++ b/apple/changelog.d/3770.added.md @@ -0,0 +1 @@ +View > Zoom In and Zoom Out (Cmd+= and Cmd+-) change the data grid's font size while a grid has focus. diff --git a/apple/changelog.d/3770.fixed.1.md b/apple/changelog.d/3770.fixed.1.md new file mode 100644 index 0000000000..8bd92284a5 --- /dev/null +++ b/apple/changelog.d/3770.fixed.1.md @@ -0,0 +1 @@ +The theme editor preview kept the old font size after the editor or data grid font changed. diff --git a/apple/changelog.d/3770.fixed.md b/apple/changelog.d/3770.fixed.md new file mode 100644 index 0000000000..02aa4a9cf5 --- /dev/null +++ b/apple/changelog.d/3770.fixed.md @@ -0,0 +1 @@ +Large Data Grid font sizes were cut off at the top and bottom of each row.