Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions apple/TablePro/Core/Menu/ZoomCommandResponding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
13 changes: 10 additions & 3 deletions apple/TablePro/Models/Settings/TypographySettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
17 changes: 17 additions & 0 deletions apple/TablePro/Views/Results/CellOverlayBase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -205,6 +220,7 @@ class CellOverlayBase: NSObject {
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard self?.isFollowingGrid == false else { return }
self?.handleDismiss(reason: .scroll)
}
}
Expand All @@ -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)
}
}
Expand Down
7 changes: 6 additions & 1 deletion apple/TablePro/Views/Results/Cells/DataGridMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// TablePro
//

import CoreGraphics
import AppKit

enum DataGridMetrics {
static let cellHorizontalInset: CGFloat = 4
Expand All @@ -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))
}
}
36 changes: 25 additions & 11 deletions apple/TablePro/Views/Results/DataGridCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
26 changes: 26 additions & 0 deletions apple/TablePro/Views/Results/DataGridScrollView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
11 changes: 4 additions & 7 deletions apple/TablePro/Views/Results/DataGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading
Loading