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..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 @@ -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,89 @@ 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 } + + let resolvedMinSize = Self.resolvedContentMinSize( + base: originalContentMinSize, + 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 } @@ -249,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), @@ -257,7 +345,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } installObservers() - recomputeWindowMinSize() + recomputeWindowMinimumSize() window.recalculateKeyViewLoop() } @@ -324,11 +412,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 +440,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 +609,17 @@ 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) { [weak self] in + self?.recomputeWindowMinimumSize(inspectorCollapsed: true) + } UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) - recomputeWindowMinSize() } @objc override func toggleInspector(_ sender: Any?) { @@ -560,58 +645,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..a3fe45e47 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/MainSplitViewControllerWindowMinimumSizeTests.swift @@ -0,0 +1,102 @@ +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) + } + + @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) + } + + @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) + } +}