From 6984de35e13dc7d3b4b7730a50e6ffb2c4878e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 21:59:23 +0700 Subject: [PATCH 1/3] fix(inspector): prevent window overflow on inspector pane toggle --- CHANGELOG.md | 4 + .../MainSplitViewController.swift | 164 +++++++++++------- ...ViewControllerWindowMinimumSizeTests.swift | 56 ++++++ 3 files changed, 163 insertions(+), 61 deletions(-) create mode 100644 TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e469c93..1758b0085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Toggling the right inspector in a narrow editor window now updates the window minimum width from the visible split panes, so the inspector no longer squeezes content or overflows. + ## [0.48.0] - 2026-06-02 ### Added diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index cbb3d0638..5d2fdf13f 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -44,6 +44,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! private var hasMaterializedInspector = false + private var baseWindowContentMinSize: NSSize? // MARK: - Toolbar @@ -227,7 +228,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi override func splitViewDidResizeSubviews(_ notification: Notification) { super.splitViewDidResizeSubviews(notification) - recomputeWindowMinSize() + recomputeWindowMinimumSize() } private func materializeInspectorIfNeeded() { @@ -236,6 +237,94 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi inspectorHosting.rootView = AnyView(buildInspectorView()) } + internal struct PaneMinimum { + internal let minimumThickness: CGFloat + internal let isCollapsed: Bool + } + + internal static func resolvedContentMinSize( + base: NSSize, + panes: [PaneMinimum], + dividerThickness: CGFloat + ) -> NSSize { + let visiblePanes = panes.filter { !$0.isCollapsed } + let paneWidth = visiblePanes.reduce(CGFloat.zero) { partialResult, pane in + partialResult + max(CGFloat.zero, pane.minimumThickness) + } + let dividerCount = max(visiblePanes.count - 1, 0) + let resolvedWidth = max(base.width, paneWidth + (CGFloat(dividerCount) * dividerThickness)) + return NSSize(width: resolvedWidth, height: base.height) + } + + private func recomputeWindowMinimumSize( + sidebarCollapsed: Bool? = nil, + inspectorCollapsed: Bool? = nil + ) { + guard let window = view.window else { return } + + if baseWindowContentMinSize == nil { + baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size + } + guard let baseWindowContentMinSize else { return } + + let resolvedMinSize = Self.resolvedContentMinSize( + base: baseWindowContentMinSize, + panes: [ + PaneMinimum( + minimumThickness: sidebarSplitItem?.minimumThickness ?? .zero, + isCollapsed: sidebarCollapsed ?? (sidebarSplitItem?.isCollapsed ?? true) + ), + PaneMinimum( + minimumThickness: detailSplitItem?.minimumThickness ?? .zero, + isCollapsed: detailSplitItem?.isCollapsed ?? false + ), + PaneMinimum( + minimumThickness: inspectorSplitItem?.minimumThickness ?? .zero, + isCollapsed: inspectorCollapsed ?? (inspectorSplitItem?.isCollapsed ?? true) + ) + ], + dividerThickness: splitView.dividerThickness + ) + + if window.contentMinSize != resolvedMinSize { + window.contentMinSize = resolvedMinSize + } + + let currentContentSize = window.contentRect(forFrameRect: window.frame).size + guard currentContentSize.width < resolvedMinSize.width || currentContentSize.height < resolvedMinSize.height else { return } + window.setContentSize(NSSize( + width: max(currentContentSize.width, resolvedMinSize.width), + height: max(currentContentSize.height, resolvedMinSize.height) + )) + } + + private func setCollapsed( + _ isCollapsed: Bool, + for splitItem: NSSplitViewItem?, + prepareWindowMinimumSize: (() -> Void)? = nil + ) { + guard let splitItem else { return } + + if splitItem.isCollapsed == isCollapsed { + recomputeWindowMinimumSize() + return + } + + prepareWindowMinimumSize?() + + guard view.window?.isVisible == true else { + splitItem.isCollapsed = isCollapsed + recomputeWindowMinimumSize() + return + } + + NSAnimationContext.runAnimationGroup { _ in + splitItem.animator().isCollapsed = isCollapsed + } completionHandler: { [weak self] in + self?.recomputeWindowMinimumSize() + } + } + override func viewWillAppear() { super.viewWillAppear() guard let window = view.window else { return } @@ -257,7 +346,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } installObservers() - recomputeWindowMinSize() + recomputeWindowMinimumSize() window.recalculateKeyViewLoop() } @@ -324,11 +413,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi sessionState = nil currentSession = nil sidebarContainer.updateSidebarState(nil, windowState: nil) - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = true - } else { - sidebarSplitItem.isCollapsed = true - } + setCollapsed(true, for: sidebarSplitItem) } return } @@ -356,10 +441,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } let collapseSidebar = newSession.driver == nil - if view.window?.isVisible == true { - sidebarSplitItem.animator().isCollapsed = collapseSidebar - } else { - sidebarSplitItem.isCollapsed = collapseSidebar + setCollapsed(collapseSidebar, for: sidebarSplitItem) { [weak self] in + guard !collapseSidebar else { return } + self?.recomputeWindowMinimumSize(sidebarCollapsed: false) } rebuildPanes() } @@ -526,15 +610,15 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi func showInspector() { materializeInspectorIfNeeded() - inspectorSplitItem?.animator().isCollapsed = false + setCollapsed(false, for: inspectorSplitItem) { [weak self] in + self?.recomputeWindowMinimumSize(inspectorCollapsed: false) + } UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) - recomputeWindowMinSize() } func hideInspector() { - inspectorSplitItem?.animator().isCollapsed = true + setCollapsed(true, for: inspectorSplitItem) UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) - recomputeWindowMinSize() } @objc override func toggleInspector(_ sender: Any?) { @@ -560,58 +644,16 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if sidebarSplitItem?.isCollapsed == true { sidebarState.selectedSidebarTab = tab - sidebarSplitItem?.animator().isCollapsed = false + setCollapsed(false, for: sidebarSplitItem) { [weak self] in + self?.recomputeWindowMinimumSize(sidebarCollapsed: false) + } } else if sidebarState.selectedSidebarTab == tab { - sidebarSplitItem?.animator().isCollapsed = true + setCollapsed(true, for: sidebarSplitItem) } else { sidebarState.selectedSidebarTab = tab } } - // MARK: - Dynamic Window Minimum Size - - private static let baseWindowMinWidth: CGFloat = 720 - private static let baseWindowMinHeight: CGFloat = 480 - - private func recomputeWindowMinSize() { - guard let window = view.window else { return } - let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true) - let inspectorVisible = !(inspectorSplitItem?.isCollapsed ?? true) - - let detailMin: CGFloat = detailSplitItem?.minimumThickness ?? 400 - let sidebarMin: CGFloat = sidebarSplitItem?.minimumThickness ?? 280 - let inspectorMin: CGFloat = inspectorSplitItem?.minimumThickness ?? 270 - let dividerThickness = splitView.dividerThickness - - var width: CGFloat = detailMin - if sidebarVisible { - width += sidebarMin + dividerThickness - } - if inspectorVisible { - width += inspectorMin + dividerThickness - } - - let resolvedWidth = max(Self.baseWindowMinWidth, width) - let newMinSize = NSSize(width: resolvedWidth, height: Self.baseWindowMinHeight) - - guard window.minSize != newMinSize else { return } - window.minSize = newMinSize - - var frame = window.frame - var resized = false - if frame.size.width < resolvedWidth { - frame.size.width = resolvedWidth - resized = true - } - if frame.size.height < Self.baseWindowMinHeight { - frame.size.height = Self.baseWindowMinHeight - resized = true - } - if resized { - window.setFrame(frame, display: true, animate: false) - } - } - // MARK: - Constants private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift new file mode 100644 index 000000000..aaf3b0c8d --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -0,0 +1,56 @@ +import AppKit +import Testing + +@testable import TablePro + +@Suite("MainSplitViewController window minimum size") +@MainActor +struct MainSplitViewControllerWindowMinimumSizeTests { + @Test("Uses all visible pane minimums when the inspector is shown") + func includesVisibleInspectorPane() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(size.width == 954) + #expect(size.height == 448) + } + + @Test("Keeps the base width floor when the inspector is hidden") + func keepsBaseWidthWhenInspectorHidden() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: true) + ], + dividerThickness: 2 + ) + + #expect(size.width == 720) + #expect(size.height == 448) + } + + @Test("Relaxes to the base width when only detail and inspector remain") + func keepsBaseWidthWithSidebarCollapsed() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: true), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(size.width == 720) + #expect(size.height == 448) + } +} From e24ea5b551350a040f769c5964a4f2316859b6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 22:13:39 +0700 Subject: [PATCH 2/3] fix(inspector): add resize call site, refresh base size, fix animation race, add test --- .../Infrastructure/MainSplitViewController.swift | 14 ++++++++------ ...litViewControllerWindowMinimumSizeTests.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 5d2fdf13f..c461eae37 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -44,7 +44,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! private var hasMaterializedInspector = false - private var baseWindowContentMinSize: NSSize? // MARK: - Toolbar @@ -262,10 +261,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) { guard let window = view.window else { return } - if baseWindowContentMinSize == nil { - baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size - } - guard let baseWindowContentMinSize else { return } + let baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size let resolvedMinSize = Self.resolvedContentMinSize( base: baseWindowContentMinSize, @@ -355,6 +351,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi removeObservers() } + override func splitViewDidResizeSubviews(_ notification: Notification) { + recomputeWindowMinimumSize() + } + // MARK: - Observers private func installObservers() { @@ -617,7 +617,9 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } func hideInspector() { - setCollapsed(true, for: inspectorSplitItem) + setCollapsed(true, for: inspectorSplitItem) { [weak self] in + self?.recomputeWindowMinimumSize(inspectorCollapsed: true) + } UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) } diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift index aaf3b0c8d..fb3ab22b3 100644 --- a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -53,4 +53,20 @@ struct MainSplitViewControllerWindowMinimumSizeTests { #expect(size.width == 720) #expect(size.height == 448) } + + @Test("Uses the pane sum when detail and inspector exceed the base floor") + func usesPaneSumWhenItExceedsBaseWithSidebarCollapsed() { + let size = MainSplitViewController.resolvedContentMinSize( + base: NSSize(width: 720, height: 448), + panes: [ + .init(minimumThickness: 280, isCollapsed: true), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(size.width == 802) + #expect(size.height == 448) + } } From 77923ac1b1e633d93e898d54396019df6d90baef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Thu, 28 May 2026 20:48:49 +0700 Subject: [PATCH 3/3] fix(inspector): store original content min size to prevent floor drift --- .../MainSplitViewController.swift | 13 ++++---- ...ViewControllerWindowMinimumSizeTests.swift | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index c461eae37..0f645bf82 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -44,6 +44,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi private var detailHosting: NSHostingController! private var inspectorHosting: NSHostingController! private var hasMaterializedInspector = false + private var originalContentMinSize: CGSize = .zero // MARK: - Toolbar @@ -261,10 +262,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi ) { guard let window = view.window else { return } - let baseWindowContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size - let resolvedMinSize = Self.resolvedContentMinSize( - base: baseWindowContentMinSize, + base: originalContentMinSize, panes: [ PaneMinimum( minimumThickness: sidebarSplitItem?.minimumThickness ?? .zero, @@ -334,6 +333,10 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi installToolbar(coordinator: sessionState.coordinator) } + if originalContentMinSize == .zero { + originalContentMinSize = window.contentRect(forFrameRect: NSRect(origin: .zero, size: window.minSize)).size + } + if let currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id), @@ -351,10 +354,6 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi removeObservers() } - override func splitViewDidResizeSubviews(_ notification: Notification) { - recomputeWindowMinimumSize() - } - // MARK: - Observers private func installObservers() { diff --git a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift index fb3ab22b3..a3fe45e47 100644 --- a/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -69,4 +69,34 @@ struct MainSplitViewControllerWindowMinimumSizeTests { #expect(size.width == 802) #expect(size.height == 448) } + + @Test("Returns to the original base width after showing then hiding the inspector") + func relaxesBackToOriginalBaseAfterInspectorCycle() { + let originalBase = NSSize(width: 720, height: 448) + + let shownSize = MainSplitViewController.resolvedContentMinSize( + base: originalBase, + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: false) + ], + dividerThickness: 2 + ) + + #expect(shownSize.width == 954) + + let hiddenSize = MainSplitViewController.resolvedContentMinSize( + base: originalBase, + panes: [ + .init(minimumThickness: 280, isCollapsed: false), + .init(minimumThickness: 400, isCollapsed: false), + .init(minimumThickness: 270, isCollapsed: true) + ], + dividerThickness: 2 + ) + + #expect(hiddenSize.width == 720) + #expect(hiddenSize.height == 448) + } }