Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 55 additions & 29 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -1282,6 +1307,7 @@ struct TabBarView: View {
isSelected: pane.selectedTabId == tab.id,
showsZoomIndicator: showsZoomIndicator,
appearance: appearance,
fillsWidth: fillsTabsToWidth,
saturation: tabBarSaturation,
trailingSeparatorBottomInset: isImmediatelyBeforeSelected
? TabBarMetrics.selectedTabLeftSeparatorBottomInset
Expand Down
22 changes: 21 additions & 1 deletion Sources/Bonsplit/Internal/Views/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Comment on lines +26 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The @ViewBuilder if let produces two structurally different view types — self vs self.frame(minWidth:alignment:). When tabWidthMode changes at runtime the swap causes SwiftUI to unmount and remount the entire tab-row subtree, resetting @State in every TabItemView (hover, favicon, loading states). Replacing the conditional with an unconditional frame(minWidth:) where nil maps to 0 keeps the view type stable across mode changes, eliminating the remount.

Suggested change
@ViewBuilder
func tabRowFillMinWidth(_ minWidth: CGFloat?) -> some View {
if let minWidth {
frame(minWidth: minWidth, alignment: .leading)
} else {
self
}
}
func tabRowFillMinWidth(_ minWidth: CGFloat?) -> some View {
frame(minWidth: minWidth ?? 0, alignment: .leading)
}

}

private enum TabControlShortcutHintDebugSettings {
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
)
Expand Down
23 changes: 23 additions & 0 deletions Sources/Bonsplit/Public/BonsplitConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions Tests/BonsplitTests/BonsplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading