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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Enum and set value pickers now populate when the driver reports the values only inside the column type instead of as a separate list.
- The plugin list no longer goes stale. The app now checks the plugin registry for changes at launch, when the plugin browser opens, and before reporting a plugin as missing, so newly published plugins show up and install right away. A registry that cannot be reached now reports a connection problem instead of "Plugin not found". (#1799)
- Dropping a materialized view or foreign table from the sidebar now generates the correct DROP statement instead of DROP TABLE, and drop and truncate statements are schema-qualified. ClickHouse now lists materialized views as their own sidebar section and drops them with the DROP VIEW syntax it requires. (#1800)
- Scrolling no longer goes dead across the whole app after opening an ER diagram and switching tabs while the pointer rests over the canvas. Diagram pan and zoom now handle scroll events on the canvas itself instead of intercepting them app-wide. (#1805)

## [0.54.0] - 2026-06-30

Expand Down
26 changes: 26 additions & 0 deletions TablePro/Models/ERDiagram/ERDiagramScrollTranslator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import CoreGraphics

enum ERDiagramScrollAction: Equatable {
case pan(CGPoint)
case zoom(CGFloat)
}

enum ERDiagramScrollTranslator {
static func action(
scrollingDeltaX: CGFloat,
scrollingDeltaY: CGFloat,
hasPreciseScrollingDeltas: Bool,
isZoomModifierActive: Bool,
currentOffset: CGPoint,
currentMagnification: CGFloat
) -> ERDiagramScrollAction {
if isZoomModifierActive {
return .zoom(currentMagnification + scrollingDeltaY * 0.01)
}
let multiplier: CGFloat = hasPreciseScrollingDeltas ? 1.0 : 10.0
return .pan(CGPoint(
x: currentOffset.x + scrollingDeltaX * multiplier,
y: currentOffset.y + scrollingDeltaY * multiplier
))
}
}
1 change: 0 additions & 1 deletion TablePro/ViewModels/ERDiagramViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ final class ERDiagramViewModel {

var canvasOffset: CGPoint = .zero
var viewportSize: CGSize = .zero
var isMouseOverCanvas = false

// MARK: - Drag State

Expand Down
57 changes: 57 additions & 0 deletions TablePro/Views/ERDiagram/ERDiagramCanvasContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import AppKit
import SwiftUI

struct ERDiagramCanvasContainer<Content: View>: NSViewRepresentable {
let viewModel: ERDiagramViewModel
@ViewBuilder let content: () -> Content

func makeNSView(context: Context) -> ERDiagramCanvasContainerView<Content> {
ERDiagramCanvasContainerView(rootView: content(), viewModel: viewModel)
}

func updateNSView(_ nsView: ERDiagramCanvasContainerView<Content>, context: Context) {
nsView.hostingView.rootView = content()
}
}

@MainActor
final class ERDiagramCanvasContainerView<Content: View>: NSView {
let hostingView: NSHostingView<Content>
private let viewModel: ERDiagramViewModel

init(rootView: Content, viewModel: ERDiagramViewModel) {
self.viewModel = viewModel
hostingView = NSHostingView(rootView: rootView)
super.init(frame: .zero)
hostingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(hostingView)
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingView.topAnchor.constraint(equalTo: topAnchor),
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) not supported")
}

override func scrollWheel(with event: NSEvent) {
let action = ERDiagramScrollTranslator.action(
scrollingDeltaX: event.scrollingDeltaX,
scrollingDeltaY: event.scrollingDeltaY,
hasPreciseScrollingDeltas: event.hasPreciseScrollingDeltas,
isZoomModifierActive: event.modifierFlags.contains(.command),
currentOffset: viewModel.canvasOffset,
currentMagnification: viewModel.magnification
)
switch action {
case .pan(let offset):
viewModel.canvasOffset = offset
case .zoom(let magnification):
viewModel.zoom(to: magnification)
}
}
}
28 changes: 1 addition & 27 deletions TablePro/Views/ERDiagram/ERDiagramView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ struct ERDiagramView: View {
@Bindable var viewModel: ERDiagramViewModel
@Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
@State private var selectedNodeId: UUID?
@State private var scrollMonitor: Any?
@State private var currentCursor: NSCursor?
@State private var magnifyStartMag: CGFloat?

Expand Down Expand Up @@ -48,7 +47,7 @@ struct ERDiagramView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
diagramContent
ERDiagramCanvasContainer(viewModel: viewModel) { diagramContent }
}
ERDiagramToolbar(viewModel: viewModel, onExport: exportDiagram)
.onKeyPress(characters: .init(charactersIn: "c"), phases: .down) { keyPress in
Expand Down Expand Up @@ -123,7 +122,6 @@ struct ERDiagramView: View {
.onContinuousHover { phase in
switch phase {
case .active(let location):
viewModel.isMouseOverCanvas = true
guard !viewModel.isDragging else { return }
let desired: NSCursor? = nodeAt(point: location) != nil ? .openHand : nil
if desired !== currentCursor {
Expand All @@ -132,7 +130,6 @@ struct ERDiagramView: View {
currentCursor = desired
}
case .ended:
viewModel.isMouseOverCanvas = false
if currentCursor != nil {
NSCursor.pop()
currentCursor = nil
Expand All @@ -141,29 +138,6 @@ struct ERDiagramView: View {
break
}
}
.onAppear {
guard scrollMonitor == nil else { return }
scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
guard viewModel.isMouseOverCanvas else { return event }
if event.modifierFlags.contains(.command) {
let zoomDelta = event.scrollingDeltaY * 0.01
viewModel.zoom(to: viewModel.magnification + zoomDelta)
return nil
}
let multiplier: CGFloat = event.hasPreciseScrollingDeltas ? 1.0 : 10.0
viewModel.canvasOffset = CGPoint(
x: viewModel.canvasOffset.x + event.scrollingDeltaX * multiplier,
y: viewModel.canvasOffset.y + event.scrollingDeltaY * multiplier
)
return nil
}
}
.onDisappear {
if let monitor = scrollMonitor {
NSEvent.removeMonitor(monitor)
scrollMonitor = nil
}
}
}

// MARK: - Cluster Colors
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import CoreGraphics
import Testing

@testable import TablePro

@Suite("ERDiagramScrollTranslator")
struct ERDiagramScrollTranslatorTests {
@Test("precise deltas pan point for point")
func preciseDeltasPanUnscaled() {
let action = ERDiagramScrollTranslator.action(
scrollingDeltaX: 12,
scrollingDeltaY: -8,
hasPreciseScrollingDeltas: true,
isZoomModifierActive: false,
currentOffset: CGPoint(x: 100, y: 50),
currentMagnification: 1.0
)
#expect(action == .pan(CGPoint(x: 112, y: 42)))
}

@Test("line deltas pan with the wheel multiplier")
func lineDeltasPanScaled() {
let action = ERDiagramScrollTranslator.action(
scrollingDeltaX: 2,
scrollingDeltaY: 3,
hasPreciseScrollingDeltas: false,
isZoomModifierActive: false,
currentOffset: .zero,
currentMagnification: 1.0
)
#expect(action == .pan(CGPoint(x: 20, y: 30)))
}

@Test("zoom modifier zooms from the vertical delta")
func zoomModifierZooms() {
let action = ERDiagramScrollTranslator.action(
scrollingDeltaX: 5,
scrollingDeltaY: 50,
hasPreciseScrollingDeltas: true,
isZoomModifierActive: true,
currentOffset: .zero,
currentMagnification: 1.0
)
guard case .zoom(let magnification) = action else {
Issue.record("expected zoom, got \(action)")
return
}
#expect(abs(magnification - 1.5) < 0.0001)
}

@Test("zero deltas keep the offset")
func zeroDeltasKeepOffset() {
let offset = CGPoint(x: 33, y: -7)
let action = ERDiagramScrollTranslator.action(
scrollingDeltaX: 0,
scrollingDeltaY: 0,
hasPreciseScrollingDeltas: true,
isZoomModifierActive: false,
currentOffset: offset,
currentMagnification: 1.0
)
#expect(action == .pan(offset))
}

@Test("negative deltas invert both axes")
func negativeDeltasInvert() {
let action = ERDiagramScrollTranslator.action(
scrollingDeltaX: -4,
scrollingDeltaY: -6,
hasPreciseScrollingDeltas: false,
isZoomModifierActive: false,
currentOffset: CGPoint(x: 100, y: 100),
currentMagnification: 1.0
)
#expect(action == .pan(CGPoint(x: 60, y: 40)))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import AppKit
import SwiftUI
import Testing

@testable import TablePro

@Suite("ERDiagramCanvasContainerView scroll routing")
@MainActor
struct ERDiagramCanvasContainerViewTests {
private func makeContainer() -> (ERDiagramCanvasContainerView<Color>, ERDiagramViewModel) {
let viewModel = ERDiagramViewModel(connectionId: UUID(), schemaKey: "test")
let view = ERDiagramCanvasContainerView(rootView: Color.clear, viewModel: viewModel)
return (view, viewModel)
}

private func makeScrollEvent(
deltaX: Int32,
deltaY: Int32,
units: CGScrollEventUnit,
flags: CGEventFlags = []
) -> NSEvent? {
guard let cgEvent = CGEvent(
scrollWheelEvent2Source: nil,
units: units,
wheelCount: 2,
wheel1: deltaY,
wheel2: deltaX,
wheel3: 0
) else { return nil }
cgEvent.flags = flags
return NSEvent(cgEvent: cgEvent)
}

@Test("trackpad scroll pans the canvas by the event deltas")
func trackpadScrollPans() throws {
let (view, viewModel) = makeContainer()
let event = try #require(makeScrollEvent(deltaX: 12, deltaY: -8, units: .pixel))
try #require(event.hasPreciseScrollingDeltas)
try #require(event.scrollingDeltaY != 0)

view.scrollWheel(with: event)

#expect(viewModel.canvasOffset == CGPoint(x: event.scrollingDeltaX, y: event.scrollingDeltaY))
}

@Test("mouse wheel scroll pans with the line multiplier")
func mouseWheelScrollPans() throws {
let (view, viewModel) = makeContainer()
let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 3, units: .line))
try #require(!event.hasPreciseScrollingDeltas)
try #require(event.scrollingDeltaY != 0)

view.scrollWheel(with: event)

#expect(viewModel.canvasOffset.y == event.scrollingDeltaY * 10)
}

@Test("command scroll zooms through the view model")
func commandScrollZooms() throws {
let (view, viewModel) = makeContainer()
let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 40, units: .pixel, flags: .maskCommand))
try #require(event.scrollingDeltaY != 0)

view.scrollWheel(with: event)

let expected = 1.0 + event.scrollingDeltaY * 0.01
#expect(abs(viewModel.magnification - expected) < 0.0001)
#expect(viewModel.canvasOffset == .zero)
}

@Test("command scroll zoom clamps to the maximum magnification")
func commandScrollZoomClamps() throws {
let (view, viewModel) = makeContainer()
let event = try #require(makeScrollEvent(deltaX: 0, deltaY: 400, units: .pixel, flags: .maskCommand))
try #require(event.scrollingDeltaY >= 200)

view.scrollWheel(with: event)

#expect(viewModel.magnification == 3.0)
}
}
Loading