From 0f1628f88cba5608b98978ff04adb4d3fa3d5686 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 14 Jul 2026 21:04:28 +0700 Subject: [PATCH 1/2] fix(coordinator): stop a Users & Roles tab from pinning the window split dividers (#1872) --- CHANGELOG.md | 1 + CLAUDE.md | 2 + .../MainSplitViewController.swift | 65 ++++++++++++----- .../Components/AutosavingSplitView.swift | 62 ++++++++++++++-- .../Extensions/MainContentView+Setup.swift | 1 + .../UsersRoles/PrivilegeEditorPane.swift | 6 +- .../UsersRoles/UsersRolesLayoutMetrics.swift | 13 ++++ .../Views/UsersRoles/UsersRolesTabView.swift | 7 +- ...nSplitViewControllerDetailWidthTests.swift | 70 +++++++++++++++++++ 9 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift create mode 100644 TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6625f2e86..1044a2b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the right sidebar refusing to resize while a Users & Roles tab was open. The user list now collapses on its own when the tab gets too narrow to hold it. (#1872) - Cancel now stops a connection attempt right away instead of letting it run on in the background. A cancelled connection is also dropped from the last session, so restarting no longer reconnects to a host you gave up on, and it can no longer interrupt a later successful connection. (#1358) - Fixed a crash when clicking a column header on a table whose columns have comments. Sorting such a table quit the app immediately. (#1869) diff --git a/CLAUDE.md b/CLAUDE.md index d6e64d560..333d1e200 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,8 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. **Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `MainContentCoordinator.syncRecoveryList()`, activated windows only) so "Reopen Last Session" never replays a connect the user cancelled. This area shipped the same bug four times (#1185, #1358, #1369). +**Tab content must never pin the window's split dividers**: `NSSplitViewItem.minimumThickness` is a required constraint, so a nested `NSSplitViewController` reports `sum(minimums) + dividers` as its `fittingSize`. SwiftUI adopts that number for an `NSViewControllerRepresentable` and the enclosing `NSHostingView` turns it into a `minWidth` at priority 999.9, which beats the 490 (`dragThatCannotResizeWindow`) a divider drag runs at: the window's sidebar and inspector dividers go dead. Two rules follow. First, every hosting controller that is a split item's view controller sets `sizingOptions = []` (`MainSplitViewController`'s `detailHosting` and `inspectorHosting`, and both panes inside `AutosavingSplitView`), and `AutosavingSplitView` returns the proposal from `sizeThatFits` so its own minimums never escape into SwiftUI. Second, a tab that genuinely needs more width than `defaultDetailMinThickness` declares it through `resolveDetailMinimumThickness(for:)` instead of leaking it; the detail pane's minimum is a per-tab contract, and `recomputeWindowMinSize()` reads it live. AppKit will not rescue you here: `.sidebar` behaviour and `canCollapseFromWindowResize` only auto-collapse on a window live-resize, which an embedded split view never sees, and no form of collapsibility lowers `fittingSize` (only an actual `isCollapsed = true` does). `CollapsingSplitViewController` collapses the pane itself for that reason. This shipped as a dead inspector divider on Users & Roles tabs (#1872). + ### Main Coordinator Pattern `MainContentCoordinator` is the central coordinator, split across 7+ extension files in `Views/Main/Extensions/` (e.g., `+Alerts`, `+Filtering`, `+Pagination`, `+RowOperations`). When adding coordinator functionality, add a new extension file rather than growing the main file. diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 683bc25a6..f643067a0 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -203,12 +203,14 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi addSplitViewItem(sidebarSplitItem) detailHosting = NSHostingController(rootView: AnyView(Color.clear)) + detailHosting.sizingOptions = [] detailSplitItem = NSSplitViewItem(viewController: detailHosting) - detailSplitItem.minimumThickness = Self.detailMinThickness + detailSplitItem.minimumThickness = Self.resolveDetailMinimumThickness(for: payload?.tabType) detailSplitItem.holdingPriority = .defaultLow addSplitViewItem(detailSplitItem) inspectorHosting = NSHostingController(rootView: AnyView(Color.clear)) + inspectorHosting.sizingOptions = [] inspectorSplitItem = NSSplitViewItem(inspectorWithViewController: inspectorHosting) inspectorSplitItem.canCollapse = true inspectorSplitItem.minimumThickness = Self.inspectorMinThickness @@ -549,32 +551,57 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi // MARK: - Dynamic Window Minimum Size - private static let baseWindowMinWidth: CGFloat = 720 - private static let baseWindowMinHeight: CGFloat = 480 - private static let sidebarMinThickness: CGFloat = 280 + static let baseWindowMinWidth: CGFloat = 720 + static let baseWindowMinHeight: CGFloat = 480 + static let sidebarMinThickness: CGFloat = 280 + static let defaultDetailMinThickness: CGFloat = 400 + static let inspectorMinThickness: CGFloat = 270 private static let sidebarMaxThickness: CGFloat = 600 - private static let detailMinThickness: CGFloat = 400 - private static let inspectorMinThickness: CGFloat = 270 - private func recomputeWindowMinSize() { - guard let window = view.window else { return } - let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true) - let inspectorVisible = !(inspectorSplitItem?.isCollapsed ?? true) - - let detailMin = Self.detailMinThickness - let sidebarMin = Self.sidebarMinThickness - let inspectorMin = Self.inspectorMinThickness - let dividerThickness = splitView.dividerThickness + static func resolveDetailMinimumThickness(for tabType: TabType?) -> CGFloat { + guard let tabType else { return defaultDetailMinThickness } + switch tabType { + case .usersRoles: + return UsersRolesLayoutMetrics.tabMinimumWidth + case .query, .table, .createTable, .erDiagram, .serverDashboard: + return defaultDetailMinThickness + } + } - var width: CGFloat = detailMin + static func resolveWindowMinWidth( + detailMinimum: CGFloat, + sidebarVisible: Bool, + inspectorVisible: Bool, + dividerThickness: CGFloat + ) -> CGFloat { + var width = detailMinimum if sidebarVisible { - width += sidebarMin + dividerThickness + width += sidebarMinThickness + dividerThickness } if inspectorVisible { - width += inspectorMin + dividerThickness + width += inspectorMinThickness + dividerThickness } + return max(baseWindowMinWidth, width) + } - let resolvedWidth = max(Self.baseWindowMinWidth, width) + func updateDetailMinimumThickness(for tabType: TabType?) { + let resolved = Self.resolveDetailMinimumThickness(for: tabType) + guard let detailSplitItem, detailSplitItem.minimumThickness != resolved else { return } + detailSplitItem.minimumThickness = resolved + recomputeWindowMinSize() + } + + private func recomputeWindowMinSize() { + guard let window = view.window else { return } + let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true) + let inspectorVisible = !(inspectorSplitItem?.isCollapsed ?? true) + + let resolvedWidth = Self.resolveWindowMinWidth( + detailMinimum: detailSplitItem?.minimumThickness ?? Self.defaultDetailMinThickness, + sidebarVisible: sidebarVisible, + inspectorVisible: inspectorVisible, + dividerThickness: splitView.dividerThickness + ) let newMinSize = NSSize(width: resolvedWidth, height: Self.baseWindowMinHeight) guard window.minSize != newMinSize else { return } diff --git a/TablePro/Views/Components/AutosavingSplitView.swift b/TablePro/Views/Components/AutosavingSplitView.swift index 576a1482f..e917afd2b 100644 --- a/TablePro/Views/Components/AutosavingSplitView.swift +++ b/TablePro/Views/Components/AutosavingSplitView.swift @@ -17,13 +17,15 @@ struct AutosavingSplitView: NSViewControllerRepr var primaryMaximum: CGFloat? var secondaryMinimum: CGFloat var primaryHoldingPriority: NSLayoutConstraint.Priority = .defaultHigh + var collapsesPrimaryWhenTight = false @ViewBuilder let primary: () -> Primary @ViewBuilder let secondary: () -> Secondary func makeCoordinator() -> Coordinator { Coordinator() } - func makeNSViewController(context: Context) -> NSSplitViewController { - let controller = NSSplitViewController() + func makeNSViewController(context: Context) -> CollapsingSplitViewController { + let controller = CollapsingSplitViewController() + controller.collapsesPrimaryWhenTight = collapsesPrimaryWhenTight controller.splitView.isVertical = isVertical controller.splitView.dividerStyle = .thin @@ -41,7 +43,7 @@ struct AutosavingSplitView: NSViewControllerRepr let primaryItem = NSSplitViewItem(viewController: primaryController) primaryItem.minimumThickness = primaryMinimum - primaryItem.canCollapse = false + primaryItem.canCollapse = collapsesPrimaryWhenTight primaryItem.holdingPriority = primaryHoldingPriority if let primaryMaximum { primaryItem.maximumThickness = primaryMaximum @@ -58,13 +60,65 @@ struct AutosavingSplitView: NSViewControllerRepr return controller } - func updateNSViewController(_ controller: NSSplitViewController, context: Context) { + func updateNSViewController(_ controller: CollapsingSplitViewController, context: Context) { + controller.collapsesPrimaryWhenTight = collapsesPrimaryWhenTight context.coordinator.primaryController?.rootView = primary() context.coordinator.secondaryController?.rootView = secondary() } + func sizeThatFits( + _ proposal: ProposedViewSize, + nsViewController: CollapsingSplitViewController, + context: Context + ) -> CGSize? { + let natural = primaryMinimum + secondaryMinimum + let resolved = proposal.replacingUnspecifiedDimensions( + by: CGSize(width: natural, height: natural) + ) + guard resolved.width.isFinite, resolved.height.isFinite else { return nil } + return resolved + } + final class Coordinator { var primaryController: NSHostingController? var secondaryController: NSHostingController? } } + +@MainActor +internal final class CollapsingSplitViewController: NSSplitViewController { + var collapsesPrimaryWhenTight = false + + private var didAutoCollapsePrimary = false + + override func viewDidLayout() { + super.viewDidLayout() + applyAutomaticCollapse() + } + + private func applyAutomaticCollapse() { + guard collapsesPrimaryWhenTight, splitView.isVertical else { return } + guard splitViewItems.count == 2 else { return } + + let primaryItem = splitViewItems[0] + let secondaryItem = splitViewItems[1] + + let available = splitView.bounds.width + guard available > 0 else { return } + + let required = primaryItem.minimumThickness + + secondaryItem.minimumThickness + + splitView.dividerThickness + + guard available >= required else { + guard !primaryItem.isCollapsed else { return } + primaryItem.isCollapsed = true + didAutoCollapsePrimary = true + return + } + + guard didAutoCollapsePrimary, primaryItem.isCollapsed else { return } + primaryItem.isCollapsed = false + didAutoCollapsePrimary = false + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index b3019e3a7..75f8b0ca4 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -307,6 +307,7 @@ extension MainContentView { tab: selectedTab, connection: connection ) + coordinator.splitViewController?.updateDetailMinimumThickness(for: selectedTab?.tabType) viewWindow?.representedURL = selectedTab?.content.sourceFileURL viewWindow?.isDocumentEdited = selectedTab?.showsUnsavedIndicator ?? false } diff --git a/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift index 23b7a3f68..833016394 100644 --- a/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift +++ b/TablePro/Views/UsersRoles/PrivilegeEditorPane.swift @@ -7,9 +7,9 @@ struct PrivilegeEditorPane: View { var body: some View { AutosavingSplitView( autosaveName: "com.TablePro.usersRoles.privilegeSplit", - primaryMinimum: 240, - primaryMaximum: 640, - secondaryMinimum: 300 + primaryMinimum: UsersRolesLayoutMetrics.privilegeScopeMinimumWidth, + primaryMaximum: UsersRolesLayoutMetrics.privilegeScopeMaximumWidth, + secondaryMinimum: UsersRolesLayoutMetrics.privilegeChecklistMinimumWidth ) { scopePane } secondary: { diff --git a/TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift b/TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift new file mode 100644 index 000000000..bcd007165 --- /dev/null +++ b/TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift @@ -0,0 +1,13 @@ +import CoreGraphics + +internal enum UsersRolesLayoutMetrics { + static let principalListMinimumWidth: CGFloat = 200 + static let principalListMaximumWidth: CGFloat = 520 + static let principalDetailMinimumWidth: CGFloat = 560 + + static let privilegeScopeMinimumWidth: CGFloat = 240 + static let privilegeScopeMaximumWidth: CGFloat = 640 + static let privilegeChecklistMinimumWidth: CGFloat = 300 + + static var tabMinimumWidth: CGFloat { principalDetailMinimumWidth } +} diff --git a/TablePro/Views/UsersRoles/UsersRolesTabView.swift b/TablePro/Views/UsersRoles/UsersRolesTabView.swift index ffed92ebf..994d251d7 100644 --- a/TablePro/Views/UsersRoles/UsersRolesTabView.swift +++ b/TablePro/Views/UsersRoles/UsersRolesTabView.swift @@ -12,9 +12,10 @@ struct UsersRolesTabView: View { VStack(spacing: 0) { AutosavingSplitView( autosaveName: "com.TablePro.usersRoles.mainSplit", - primaryMinimum: 200, - primaryMaximum: 520, - secondaryMinimum: 560 + primaryMinimum: UsersRolesLayoutMetrics.principalListMinimumWidth, + primaryMaximum: UsersRolesLayoutMetrics.principalListMaximumWidth, + secondaryMinimum: UsersRolesLayoutMetrics.principalDetailMinimumWidth, + collapsesPrimaryWhenTight: true ) { PrincipalListPane(viewModel: viewModel) } secondary: { diff --git a/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift b/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift new file mode 100644 index 000000000..9acbab7f9 --- /dev/null +++ b/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift @@ -0,0 +1,70 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("MainSplitViewController detail width") +@MainActor +struct MainSplitViewControllerDetailWidthTests { + @Test("Nil tab type falls back to the default detail minimum") + func nilTabTypeUsesDefault() { + let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: nil) + #expect(resolved == MainSplitViewController.defaultDetailMinThickness) + } + + @Test("Users & Roles declares the width its panes actually need") + func usersRolesDeclaresItsOwnMinimum() { + let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles) + #expect(resolved == UsersRolesLayoutMetrics.tabMinimumWidth) + #expect(resolved == 560) + } + + @Test( + "Every other tab type keeps the default detail minimum", + arguments: [TabType.query, .table, .createTable, .erDiagram, .serverDashboard] + ) + func otherTabTypesUseDefault(tabType: TabType) { + let resolved = MainSplitViewController.resolveDetailMinimumThickness(for: tabType) + #expect(resolved == MainSplitViewController.defaultDetailMinThickness) + #expect(resolved == 400) + } + + @Test("Users & Roles fits its privilege editor once the principal list collapses") + func usersRolesMinimumFitsCollapsedLayout() { + let privilegeEditorWidth = UsersRolesLayoutMetrics.privilegeScopeMinimumWidth + + UsersRolesLayoutMetrics.privilegeChecklistMinimumWidth + #expect(UsersRolesLayoutMetrics.tabMinimumWidth >= privilegeEditorWidth) + } + + @Test("Both panels hidden collapses to the base window minimum") + func collapsedPanelsUseBaseWindowMinimum() { + let width = MainSplitViewController.resolveWindowMinWidth( + detailMinimum: MainSplitViewController.defaultDetailMinThickness, + sidebarVisible: false, + inspectorVisible: false, + dividerThickness: 1 + ) + #expect(width == MainSplitViewController.baseWindowMinWidth) + } + + @Test("Both panels visible sums sidebar, detail, inspector and dividers") + func visiblePanelsSumThicknesses() { + let width = MainSplitViewController.resolveWindowMinWidth( + detailMinimum: MainSplitViewController.defaultDetailMinThickness, + sidebarVisible: true, + inspectorVisible: true, + dividerThickness: 1 + ) + #expect(width == 952) + } + + @Test("A Users & Roles tab widens the window minimum instead of pinning the inspector") + func usersRolesWidensWindowMinimum() { + let width = MainSplitViewController.resolveWindowMinWidth( + detailMinimum: MainSplitViewController.resolveDetailMinimumThickness(for: .usersRoles), + sidebarVisible: true, + inspectorVisible: true, + dividerThickness: 1 + ) + #expect(width == 1_112) + } +} From cd4b5965865956054ddd407f897f3d461eaa83fc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 14 Jul 2026 21:30:47 +0700 Subject: [PATCH 2/2] fix(hig): lower split pane holding priority so dividers can be dragged (#1872) --- CHANGELOG.md | 1 + CLAUDE.md | 2 ++ ...NSLayoutConstraintPriority+SplitPane.swift | 5 ++++ .../Components/AutosavingSplitView.swift | 2 +- .../ServerDashboardSplitView.swift | 4 +-- .../UsersRoles/UsersRolesLayoutMetrics.swift | 2 +- ...nSplitViewControllerDetailWidthTests.swift | 27 +++++++++++++++++++ 7 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1044a2b58..319617737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed the right sidebar refusing to resize while a Users & Roles tab was open. The user list now collapses on its own when the tab gets too narrow to hold it. (#1872) +- Fixed dividers that could not be dragged at all: the user list and privilege panes in Users & Roles, the trigger list in Structure, and the metrics and slow query panes in Server Dashboard. (#1872) - Cancel now stops a connection attempt right away instead of letting it run on in the background. A cancelled connection is also dropped from the last session, so restarting no longer reconnects to a host you gave up on, and it can no longer interrupt a later successful connection. (#1358) - Fixed a crash when clicking a column header on a table whose columns have comments. Sorting such a table quit the app immediately. (#1869) diff --git a/CLAUDE.md b/CLAUDE.md index 333d1e200..0c53c486e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,8 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. **Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `MainContentCoordinator.syncRecoveryList()`, activated windows only) so "Reopen Last Session" never replays a connect the user cancelled. This area shipped the same bug four times (#1185, #1358, #1369). +**A split pane's `holdingPriority` must stay below 490**: AppKit applies a divider drag as a layout change at `dragThatCannotResizeWindow` (490). Any pane whose `holdingPriority` is at or above that outranks the drag, so its width constraint wins and the divider cannot move at all. `.defaultHigh` (750) freezes it outright, which shipped as three dead dividers (Users & Roles, Structure triggers, Server Dashboard). Use `.splitPaneHolding` (260, the value AppKit itself gives a sidebar item): high enough to outrank a `.defaultLow` (250) sibling so the pane holds its size when the window resizes, low enough that a drag still wins. `.defaultLow` is not the fix, since the pane then grows with the window instead of holding. (#1872) + **Tab content must never pin the window's split dividers**: `NSSplitViewItem.minimumThickness` is a required constraint, so a nested `NSSplitViewController` reports `sum(minimums) + dividers` as its `fittingSize`. SwiftUI adopts that number for an `NSViewControllerRepresentable` and the enclosing `NSHostingView` turns it into a `minWidth` at priority 999.9, which beats the 490 (`dragThatCannotResizeWindow`) a divider drag runs at: the window's sidebar and inspector dividers go dead. Two rules follow. First, every hosting controller that is a split item's view controller sets `sizingOptions = []` (`MainSplitViewController`'s `detailHosting` and `inspectorHosting`, and both panes inside `AutosavingSplitView`), and `AutosavingSplitView` returns the proposal from `sizeThatFits` so its own minimums never escape into SwiftUI. Second, a tab that genuinely needs more width than `defaultDetailMinThickness` declares it through `resolveDetailMinimumThickness(for:)` instead of leaking it; the detail pane's minimum is a per-tab contract, and `recomputeWindowMinSize()` reads it live. AppKit will not rescue you here: `.sidebar` behaviour and `canCollapseFromWindowResize` only auto-collapse on a window live-resize, which an embedded split view never sees, and no form of collapsibility lowers `fittingSize` (only an actual `isCollapsed = true` does). `CollapsingSplitViewController` collapses the pane itself for that reason. This shipped as a dead inspector divider on Users & Roles tabs (#1872). ### Main Coordinator Pattern diff --git a/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift b/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift new file mode 100644 index 000000000..0017b21d0 --- /dev/null +++ b/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift @@ -0,0 +1,5 @@ +import AppKit + +internal extension NSLayoutConstraint.Priority { + static let splitPaneHolding = NSLayoutConstraint.Priority(260) +} diff --git a/TablePro/Views/Components/AutosavingSplitView.swift b/TablePro/Views/Components/AutosavingSplitView.swift index e917afd2b..ed1d8e1d4 100644 --- a/TablePro/Views/Components/AutosavingSplitView.swift +++ b/TablePro/Views/Components/AutosavingSplitView.swift @@ -16,7 +16,7 @@ struct AutosavingSplitView: NSViewControllerRepr var primaryMinimum: CGFloat var primaryMaximum: CGFloat? var secondaryMinimum: CGFloat - var primaryHoldingPriority: NSLayoutConstraint.Priority = .defaultHigh + var primaryHoldingPriority: NSLayoutConstraint.Priority = .splitPaneHolding var collapsesPrimaryWhenTight = false @ViewBuilder let primary: () -> Primary @ViewBuilder let secondary: () -> Secondary diff --git a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift index 5e1a747a3..761a9e399 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift @@ -60,7 +60,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { let item = NSSplitViewItem(viewController: controller) item.minimumThickness = 76 item.maximumThickness = 200 - item.holdingPriority = .defaultHigh + item.holdingPriority = .splitPaneHolding coordinator.metricsController = controller return item @@ -74,7 +74,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { let item = NSSplitViewItem(viewController: controller) item.minimumThickness = 100 item.canCollapse = true - item.holdingPriority = .defaultHigh + item.holdingPriority = .splitPaneHolding coordinator.slowQueriesController = controller return item } diff --git a/TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift b/TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift index bcd007165..d5c2bef8e 100644 --- a/TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift +++ b/TablePro/Views/UsersRoles/UsersRolesLayoutMetrics.swift @@ -5,7 +5,7 @@ internal enum UsersRolesLayoutMetrics { static let principalListMaximumWidth: CGFloat = 520 static let principalDetailMinimumWidth: CGFloat = 560 - static let privilegeScopeMinimumWidth: CGFloat = 240 + static let privilegeScopeMinimumWidth: CGFloat = 200 static let privilegeScopeMaximumWidth: CGFloat = 640 static let privilegeChecklistMinimumWidth: CGFloat = 300 diff --git a/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift b/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift index 9acbab7f9..69b6bb741 100644 --- a/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift +++ b/TableProTests/Services/MainSplitViewControllerDetailWidthTests.swift @@ -1,7 +1,34 @@ +import AppKit import Foundation @testable import TablePro import Testing +@Suite("Split pane holding priority") +@MainActor +struct SplitPaneHoldingPriorityTests { + private static let dragThatCannotResizeWindow: Float = 490 + + @Test("A held pane still yields to a divider drag") + func heldPaneYieldsToDivider() { + #expect(NSLayoutConstraint.Priority.splitPaneHolding.rawValue < Self.dragThatCannotResizeWindow) + } + + @Test("A held pane still outranks its sibling when the container resizes") + func heldPaneOutranksSibling() { + #expect( + NSLayoutConstraint.Priority.splitPaneHolding.rawValue + > NSLayoutConstraint.Priority.defaultLow.rawValue + ) + } + + @Test("Privilege editor keeps room to drag at the tab's minimum width") + func privilegeEditorKeepsDragRoom() { + let required = UsersRolesLayoutMetrics.privilegeScopeMinimumWidth + + UsersRolesLayoutMetrics.privilegeChecklistMinimumWidth + #expect(UsersRolesLayoutMetrics.tabMinimumWidth - required >= 50) + } +} + @Suite("MainSplitViewController detail width") @MainActor struct MainSplitViewControllerDetailWidthTests {