diff --git a/CHANGELOG.md b/CHANGELOG.md index 622233b9..cb1f4572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to Bonsplit will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- `BonsplitConfiguration.Appearance.tabWidthMode` (`TabWidthMode`) to control tab sizing. + - `.fixed` (default) keeps the historical fixed-width + horizontal-scroll layout unchanged. + - `.fill` stretches tabs to fill the pane's available tab-bar width, distributing the + space equally; a single tab spans the full width. Tabs fall back to fixed sizing and + scroll when they would overflow at their natural width. + ## [1.1.1] - 2025-01-29 ### Fixed diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 68ee011a..f704ba2a 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -950,6 +950,20 @@ struct TabBarView: View { controller.configuration.appearance } + /// Whether tabs should stretch to fill the pane's available tab-bar width. + private var fillsTabsToWidth: Bool { + appearance.tabWidthMode == .fill + } + + /// Minimum width to impose on the (already trailing-inset-padded) tab row when + /// filling, so the horizontal `ScrollView` hands the row the full viewport and + /// SwiftUI distributes the slack across the flexible tabs. `nil` in fixed mode + /// (and before the container width is known) leaves the historical layout intact. + private var fillRowMinWidth: CGFloat? { + guard fillsTabsToWidth, containerWidth > 0 else { return nil } + return containerWidth + } + private var tabBarHeight: CGFloat { tabBarLayout.barHeight } @@ -1072,6 +1086,45 @@ struct TabBarView: View { } + /// The horizontally-scrolling tab row hosted inside the tab strip's `ScrollView`. + /// + /// Extracted from `body` so the SwiftUI type-checker can resolve the surrounding + /// view tree in reasonable time. In fill mode `tabRowFillMinWidth` forces the row + /// to the viewport width so the flexible tabs distribute the slack. + @ViewBuilder + private var tabScrollContent: some View { + HStack(spacing: TabBarMetrics.tabSpacing) { + Color.clear + .frame(width: 0, height: tabBarHeight) + .id(leadingScrollAnchorId) + + ForEach(Array(pane.tabs.enumerated()), id: \.element.id) { index, tab in + tabItem(for: tab, at: index) + .id(tab.id) + } + + // Unified drop zone after the last tab. + dropZoneAfterTabs + } + .padding(.horizontal, TabBarMetrics.barPadding) + .padding(.trailing, trailingTabContentInset) + .tabRowFillMinWidth(fillRowMinWidth) + .frame(height: tabBarHeight, alignment: .top) + .animation(nil, value: pane.tabs.map(\.id)) + .background( + GeometryReader { contentGeo in + Color.clear + .onChange(of: contentGeo.frame(in: .named("tabScroll"))) { _, newFrame in + updateTabScrollContent(frame: newFrame) + } + .onAppear { + let frame = contentGeo.frame(in: .named("tabScroll")) + updateTabScrollContent(frame: frame) + } + } + ) + } + var body: some View { HStack(spacing: 0) { if appearance.tabBarLeadingInset > 0 && controller.internalController.rootNode.allPaneIds.first == pane.id { @@ -1086,35 +1139,7 @@ struct TabBarView: View { GeometryReader { containerGeo in ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: TabBarMetrics.tabSpacing) { - Color.clear - .frame(width: 0, height: tabBarHeight) - .id(leadingScrollAnchorId) - - ForEach(Array(pane.tabs.enumerated()), id: \.element.id) { index, tab in - tabItem(for: tab, at: index) - .id(tab.id) - } - - // Unified drop zone after the last tab. - dropZoneAfterTabs - } - .padding(.horizontal, TabBarMetrics.barPadding) - .padding(.trailing, trailingTabContentInset) - .frame(height: tabBarHeight, alignment: .top) - .animation(nil, value: pane.tabs.map(\.id)) - .background( - GeometryReader { contentGeo in - Color.clear - .onChange(of: contentGeo.frame(in: .named("tabScroll"))) { _, newFrame in - updateTabScrollContent(frame: newFrame) - } - .onAppear { - let frame = contentGeo.frame(in: .named("tabScroll")) - updateTabScrollContent(frame: frame) - } - } - ) + tabScrollContent } .background( TabBarScrollViewResolver { scrollView in @@ -1282,6 +1307,7 @@ struct TabBarView: View { isSelected: pane.selectedTabId == tab.id, showsZoomIndicator: showsZoomIndicator, appearance: appearance, + fillsWidth: fillsTabsToWidth, saturation: tabBarSaturation, trailingSeparatorBottomInset: isImmediatelyBeforeSelected ? TabBarMetrics.selectedTabLeftSeparatorBottomInset diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 2e9bbbdc..79d4d772 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -16,6 +16,21 @@ extension View { transaction.animation = nil } } + + /// Imposes a minimum width on the tab row only when `minWidth` is non-nil. + /// + /// Used by the tab strip's fill mode to force the horizontal `ScrollView` to hand + /// the row the full viewport width so SwiftUI can distribute slack across flexible + /// tabs. Passing `nil` returns the view untouched, preserving the fixed-width layout + /// byte-for-byte. + @ViewBuilder + func tabRowFillMinWidth(_ minWidth: CGFloat?) -> some View { + if let minWidth { + frame(minWidth: minWidth, alignment: .leading) + } else { + self + } + } } private enum TabControlShortcutHintDebugSettings { @@ -64,6 +79,9 @@ struct TabItemView: View { let isSelected: Bool let showsZoomIndicator: Bool let appearance: BonsplitConfiguration.Appearance + /// When true, the tab drops its fixed maximum width and grows to fill the slack + /// the enclosing tab strip distributes (see ``BonsplitConfiguration/Appearance/tabWidthMode``). + let fillsWidth: Bool let saturation: Double let trailingSeparatorBottomInset: CGFloat let controlShortcutDigit: Int? @@ -211,7 +229,9 @@ struct TabItemView: View { .padding(.horizontal, TabBarMetrics.tabHorizontalPadding) .frame( minWidth: tabWidthRange.lowerBound, - maxWidth: tabWidthRange.upperBound, + // In fill mode the tab becomes flexible so the tab strip can distribute + // slack equally across tabs; the fixed upper bound only applies otherwise. + maxWidth: fillsWidth ? .infinity : tabWidthRange.upperBound, minHeight: tabHeight, maxHeight: tabHeight ) diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index 75145fa6..4b5bf02a 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -449,6 +449,23 @@ extension BonsplitConfiguration { } } + /// Controls how surface tabs are sized within a pane's tab bar. + public enum TabWidthMode: Sendable, Equatable, Codable { + /// Tabs use a fixed width clamped to `tabMinWidth...tabMaxWidth` and the + /// tab strip scrolls horizontally once the tabs overflow the pane. This is + /// the default and preserves Bonsplit's historical layout exactly. + case fixed + + /// Tabs stretch to fill the pane's available tab-bar width, distributing the + /// space equally between them. + /// + /// A single tab spans the full available width; multiple tabs share it + /// evenly. When the tabs would overflow the pane at their natural width, + /// the strip falls back to ``fixed`` sizing and scrolls, so this mode never + /// shrinks tabs below their natural width. + case fill + } + // MARK: - Tab Bar /// Height of the tab bar @@ -468,6 +485,10 @@ extension BonsplitConfiguration { /// Spacing between tabs public var tabSpacing: CGFloat + /// Controls whether tabs use a fixed width and scroll, or stretch to fill the + /// pane's available tab-bar width. Defaults to ``TabWidthMode/fixed``. + public var tabWidthMode: TabWidthMode + // MARK: - Split View /// Minimum width of a pane @@ -550,6 +571,7 @@ extension BonsplitConfiguration { tabMaxWidth: CGFloat = 220, tabTitleFontSize: CGFloat = 11, tabSpacing: CGFloat = 0, + tabWidthMode: TabWidthMode = .fixed, minimumPaneWidth: CGFloat = 100, minimumPaneHeight: CGFloat = 100, showSplitButtons: Bool = true, @@ -569,6 +591,7 @@ extension BonsplitConfiguration { self.tabMaxWidth = tabMaxWidth self.tabTitleFontSize = tabTitleFontSize self.tabSpacing = tabSpacing + self.tabWidthMode = tabWidthMode self.minimumPaneWidth = minimumPaneWidth self.minimumPaneHeight = minimumPaneHeight self.showSplitButtons = showSplitButtons diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index c6eb18a7..274906b5 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -4316,4 +4316,27 @@ final class BonsplitTests: XCTestCase { } return event } + + // MARK: - Tab Width Mode + + /// The tab strip must default to fixed-width sizing so existing layouts are + /// unchanged; fill is strictly opt-in. + func testTabWidthModeDefaultsToFixed() { + XCTAssertEqual(BonsplitConfiguration.Appearance().tabWidthMode, .fixed) + XCTAssertEqual(BonsplitConfiguration.Appearance.default.tabWidthMode, .fixed) + XCTAssertEqual(BonsplitConfiguration.Appearance.compact.tabWidthMode, .fixed) + XCTAssertEqual(BonsplitConfiguration.Appearance.spacious.tabWidthMode, .fixed) + XCTAssertEqual(BonsplitConfiguration.default.appearance.tabWidthMode, .fixed) + } + + /// Opting into fill is preserved on the configuration and is distinct from fixed. + func testTabWidthModeFillIsSettableAndDistinct() { + var appearance = BonsplitConfiguration.Appearance() + appearance.tabWidthMode = .fill + XCTAssertEqual(appearance.tabWidthMode, .fill) + XCTAssertNotEqual(BonsplitConfiguration.Appearance.TabWidthMode.fill, .fixed) + + let configured = BonsplitConfiguration.Appearance(tabWidthMode: .fill) + XCTAssertEqual(configured.tabWidthMode, .fill) + } }