diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index 9d5585ab..4c147d5f 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -10,13 +10,19 @@ struct RootView: View { @Environment(\.colorScheme) private var colorScheme @State private var window: WindowModel @State private var hostsPageState = HostsPageState() - /// The last usable width reported by whichever sidebar is mounted. + /// The last usable leading edge reported by whichever detail column is mounted. /// - /// Keeping this at the window shell lets the pinned tabs track both the - /// Vault split view and the shared contextual sidebar. A hidden sidebar can - /// briefly report zero while it leaves the layout; that value is rejected - /// by `recordSidebarWidth` so the tab strip keeps the user's last width. - @State private var sidebarWidth = Metrics.Sidebar.ideal + /// The split view puts its native splitter outside the sidebar content, so + /// measuring that content stops short of the visual column boundary. The + /// detail column begins at the boundary the pinned tabs need to meet. A + /// hidden sidebar collapses that edge close to zero; measurements below the + /// split view's minimum sidebar width are rejected so the tab strip keeps + /// the user's last visible boundary. + @State private var pinnedTabStripWidth = Metrics.Sidebar.ideal + + /// `nonisolated` because geometry transforms are `Sendable` closures and + /// this coordinate-space name is immutable, not main-actor state. + nonisolated private static let layoutSpace = "termind.rootLayout" init(window: WindowModel = WindowModel()) { _window = State(initialValue: window) @@ -27,7 +33,7 @@ struct RootView: View { @Bindable var presentations = window.presentations VStack(spacing: 0) { - TabBarView(pinnedTabStripWidth: sidebarWidth) + TabBarView(pinnedTabStripWidth: pinnedTabStripWidth) Divider() splitContent if window.selectedTab != nil { @@ -40,6 +46,7 @@ struct RootView: View { minHeight: Metrics.Window.minHeight, maxHeight: .infinity ) + .coordinateSpace(.named(Self.layoutSpace)) .toolbarBackground(WindowChrome.background(for: colorScheme), for: .windowToolbar) .toolbarBackground(.visible, for: .windowToolbar) // ⌘T and every Connect entry land here. A sheet rather than a page, @@ -90,10 +97,15 @@ struct RootView: View { // Its detail owns the optional work split so showing New Host does // not reconstruct `VaultView` and discard an active query, selected // tags, scroll position, or search-field focus. - NavigationSplitView { + NavigationSplitView(columnVisibility: sidebarVisibility) { vaultSidebar } detail: { vaultWorkspace + .onGeometryChange(for: CGFloat.self) { + $0.frame(in: .named(Self.layoutSpace)).minX + } action: { + recordPinnedTabStripWidth($0) + } } .navigationSplitViewStyle(.balanced) } else { @@ -104,11 +116,6 @@ struct RootView: View { ideal: Metrics.Sidebar.ideal, max: Metrics.Sidebar.max ) - .onGeometryChange(for: CGFloat.self) { - $0.size.width - } action: { - recordSidebarWidth($0) - } } detail: { detail .frame( @@ -117,6 +124,11 @@ struct RootView: View { minHeight: Metrics.Window.minHeight, maxHeight: .infinity ) + .onGeometryChange(for: CGFloat.self) { + $0.frame(in: .named(Self.layoutSpace)).minX + } action: { + recordPinnedTabStripWidth($0) + } } .navigationSplitViewStyle(.balanced) } @@ -129,17 +141,14 @@ struct RootView: View { ideal: Metrics.Sidebar.ideal, max: Metrics.Sidebar.max ) - .onGeometryChange(for: CGFloat.self) { - $0.size.width - } action: { - recordSidebarWidth($0) - } } - private func recordSidebarWidth(_ measuredWidth: CGFloat) { - sidebarWidth = PinnedTopLevelTabSizing.resolvedSidebarWidth( - current: sidebarWidth, - measured: measuredWidth + private func recordPinnedTabStripWidth(_ measuredDetailLeadingEdge: CGFloat) { + pinnedTabStripWidth = PinnedTopLevelTabSizing.resolvedStripWidth( + current: pinnedTabStripWidth, + measuredDetailLeadingEdge: measuredDetailLeadingEdge, + isSidebarVisible: window.showsSidebar, + minimumSidebarWidth: Metrics.Sidebar.min ) } diff --git a/Termind/Features/Tabs/PinnedTopLevelTabSizing.swift b/Termind/Features/Tabs/PinnedTopLevelTabSizing.swift index 526d1171..9d1769a2 100644 --- a/Termind/Features/Tabs/PinnedTopLevelTabSizing.swift +++ b/Termind/Features/Tabs/PinnedTopLevelTabSizing.swift @@ -4,15 +4,29 @@ import CoreGraphics nonisolated enum PinnedTopLevelTabSizing { private static let tabCount: CGFloat = 2 - /// Divides the measured sidebar equally between the two pinned tabs. - static func tabWidth(sidebarWidth: CGFloat) -> CGFloat { - sidebarWidth / tabCount + /// Divides the strip ending at the sidebar's trailing edge equally between + /// the two pinned tabs. + static func tabWidth(stripWidth: CGFloat) -> CGFloat { + stripWidth / tabCount } - /// Ignores transient invalid measurements produced while a sidebar leaves - /// the split-view layout, preserving the last width for the pinned tabs. - static func resolvedSidebarWidth(current: CGFloat, measured: CGFloat) -> CGFloat { - guard measured.isFinite, measured > 0 else { return current } - return measured + /// Ignores invalid or collapsed detail-column edges produced while a + /// sidebar leaves the split-view layout, preserving the last strip width. + /// AppKit may leave the hidden edge at a hairline rather than exactly zero, + /// so validity is based on the split view's existing minimum sidebar width. + static func resolvedStripWidth( + current: CGFloat, + measuredDetailLeadingEdge: CGFloat, + isSidebarVisible: Bool, + minimumSidebarWidth: CGFloat + ) -> CGFloat { + guard + isSidebarVisible, + measuredDetailLeadingEdge.isFinite, + measuredDetailLeadingEdge >= minimumSidebarWidth + else { + return current + } + return measuredDetailLeadingEdge } } diff --git a/Termind/Features/Tabs/TabBarView.swift b/Termind/Features/Tabs/TabBarView.swift index 2c1c764a..547ee5bb 100644 --- a/Termind/Features/Tabs/TabBarView.swift +++ b/Termind/Features/Tabs/TabBarView.swift @@ -130,7 +130,7 @@ struct TabBarView: View { /// drop on this edge means the same thing as the first insertion slot. private var leadingStrip: some View { let pinnedTabWidth = PinnedTopLevelTabSizing.tabWidth( - sidebarWidth: pinnedTabStripWidth + stripWidth: pinnedTabStripWidth ) return HStack(spacing: 0) { diff --git a/TermindTests/PinnedTopLevelTabSizingTests.swift b/TermindTests/PinnedTopLevelTabSizingTests.swift index b09f3b14..6afe4c10 100644 --- a/TermindTests/PinnedTopLevelTabSizingTests.swift +++ b/TermindTests/PinnedTopLevelTabSizingTests.swift @@ -10,35 +10,53 @@ struct PinnedTopLevelTabSizingTests { (Metrics.Sidebar.ideal, Metrics.Sidebar.ideal / 2), (Metrics.Sidebar.max, Metrics.Sidebar.max / 2), ]) - func twoPinnedTabsSplitTheSidebarWidthEqually( - sidebarWidth: CGFloat, + func twoPinnedTabsSplitAtTheSidebarTrailingEdgeEqually( + stripWidth: CGFloat, expectedTabWidth: CGFloat ) { - let tabWidth = PinnedTopLevelTabSizing.tabWidth(sidebarWidth: sidebarWidth) + let tabWidth = PinnedTopLevelTabSizing.tabWidth(stripWidth: stripWidth) #expect(tabWidth == expectedTabWidth) - #expect(tabWidth * 2 == sidebarWidth) + #expect(tabWidth * 2 == stripWidth) } - @Test(arguments: [CGFloat.zero, -.infinity, .infinity, .nan]) - func invalidMeasurementsPreserveTheLastSidebarWidth(measuredWidth: CGFloat) { + @Test(arguments: [CGFloat.zero, 1, Metrics.Sidebar.min - 1, -.infinity, .infinity, .nan]) + func invalidOrCollapsedDetailLeadingEdgesPreserveTheLastStripWidth( + measuredDetailLeadingEdge: CGFloat + ) { let currentWidth = Metrics.Sidebar.ideal #expect( - PinnedTopLevelTabSizing.resolvedSidebarWidth( + PinnedTopLevelTabSizing.resolvedStripWidth( current: currentWidth, - measured: measuredWidth + measuredDetailLeadingEdge: measuredDetailLeadingEdge, + isSidebarVisible: true, + minimumSidebarWidth: Metrics.Sidebar.min ) == currentWidth ) } @Test - func positiveMeasurementReplacesThePreviousSidebarWidth() { + func positiveDetailLeadingEdgeReplacesThePreviousStripWidth() { #expect( - PinnedTopLevelTabSizing.resolvedSidebarWidth( + PinnedTopLevelTabSizing.resolvedStripWidth( current: Metrics.Sidebar.ideal, - measured: Metrics.Sidebar.max - ) == Metrics.Sidebar.max + measuredDetailLeadingEdge: 270.5, + isSidebarVisible: true, + minimumSidebarWidth: Metrics.Sidebar.min + ) == 270.5 + ) + } + + @Test + func visibleAnimationMeasurementsDoNotReplaceTheLastStripWidthAfterHiding() { + #expect( + PinnedTopLevelTabSizing.resolvedStripWidth( + current: 400.5, + measuredDetailLeadingEdge: 272, + isSidebarVisible: false, + minimumSidebarWidth: Metrics.Sidebar.min + ) == 400.5 ) } }