Fix pinned tab alignment with sidebar

This commit is contained in:
Kaede Akino committed 2026-08-25 12:26:01 +08:00
1 parent a0441d8c97
commit 7b920db5c5
4 files changed
+84 -43

No files matched your search

+31 -22
View File
@@ -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
)
}
@@ -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
}
}
+1 -1
View File
@@ -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) {
+30 -12
View File
@@ -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
)
}
}