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
30 changes: 15 additions & 15 deletions Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,33 @@ import Foundation
enum TabBarMetrics {
// MARK: - Tab Bar

static let barHeight: CGFloat = 30
static func barHeight(_ scale: CGFloat) -> CGFloat { 30 * scale }
static let barPadding: CGFloat = 0

// MARK: - Individual Tabs

static let tabHeight: CGFloat = 30
static let tabMinWidth: CGFloat = 48
static let tabMaxWidth: CGFloat = 220
static func tabHeight(_ scale: CGFloat) -> CGFloat { 30 * scale }
static func tabMinWidth(_ scale: CGFloat) -> CGFloat { 48 * scale }
static func tabMaxWidth(_ scale: CGFloat) -> CGFloat { 220 * scale }
static let tabCornerRadius: CGFloat = 0
static let tabHorizontalPadding: CGFloat = 6
static func tabHorizontalPadding(_ scale: CGFloat) -> CGFloat { 6 * scale }
static let tabSpacing: CGFloat = 0
static let activeIndicatorHeight: CGFloat = 2
static func activeIndicatorHeight(_ scale: CGFloat) -> CGFloat { 2 * scale }

// MARK: - Tab Content

static let iconSize: CGFloat = 14
static let titleFontSize: CGFloat = 11
static let closeButtonSize: CGFloat = 16
static let closeIconSize: CGFloat = 9
static let dirtyIndicatorSize: CGFloat = 8
static let notificationBadgeSize: CGFloat = 6
static let contentSpacing: CGFloat = 6
static func iconSize(_ scale: CGFloat) -> CGFloat { 14 * scale }
static func titleFontSize(_ scale: CGFloat) -> CGFloat { 11 * scale }
static func closeButtonSize(_ scale: CGFloat) -> CGFloat { 16 * scale }
static func closeIconSize(_ scale: CGFloat) -> CGFloat { 9 * scale }
static func dirtyIndicatorSize(_ scale: CGFloat) -> CGFloat { 8 * scale }
static func notificationBadgeSize(_ scale: CGFloat) -> CGFloat { 6 * scale }
static func contentSpacing(_ scale: CGFloat) -> CGFloat { 6 * scale }

// MARK: - Drop Indicator

static let dropIndicatorWidth: CGFloat = 2
static let dropIndicatorHeight: CGFloat = 20
static func dropIndicatorWidth(_ scale: CGFloat) -> CGFloat { 2 * scale }
static func dropIndicatorHeight(_ scale: CGFloat) -> CGFloat { 20 * scale }

// MARK: - Split View

Expand Down
28 changes: 15 additions & 13 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ struct TabContextMenuState {
struct TabBarView: View {
@Environment(BonsplitController.self) private var controller
@Environment(SplitViewController.self) private var splitViewController

@Environment(\.bonsplitZoomScale) private var zoomScale

@Bindable var pane: PaneState
let isFocused: Bool
var showSplitButtons: Bool = true
Expand Down Expand Up @@ -137,7 +138,7 @@ struct TabBarView: View {
let trailing = max(0, containerGeo.size.width - contentWidth)
if trailing >= 1 {
Color.clear
.frame(width: trailing, height: TabBarMetrics.tabHeight)
.frame(width: trailing, height: TabBarMetrics.tabHeight(zoomScale))
.contentShape(Rectangle())
.onDrop(of: [.tabTransfer], delegate: TabDropDelegate(
targetIndex: pane.tabs.count,
Expand Down Expand Up @@ -169,7 +170,7 @@ struct TabBarView: View {
}
}
}
.frame(height: TabBarMetrics.barHeight)
.frame(height: TabBarMetrics.barHeight(zoomScale))
.overlay(fadeOverlays)
}

Expand All @@ -179,7 +180,7 @@ struct TabBarView: View {
.saturation(tabBarSaturation)
}
}
.frame(height: TabBarMetrics.barHeight)
.frame(height: TabBarMetrics.barHeight(zoomScale))
.coordinateSpace(name: "tabBar")
.contentShape(Rectangle())
.background(tabBarBackground)
Expand Down Expand Up @@ -410,7 +411,7 @@ struct TabBarView: View {
private var dropZoneAfterTabs: some View {
Rectangle()
.fill(Color.clear)
.frame(width: 30, height: TabBarMetrics.tabHeight)
.frame(width: 30 * zoomScale, height: TabBarMetrics.tabHeight(zoomScale))
.contentShape(Rectangle())
.onDrop(of: [.tabTransfer], delegate: TabDropDelegate(
targetIndex: pane.tabs.count,
Expand All @@ -434,7 +435,7 @@ struct TabBarView: View {
private var dropIndicator: some View {
Capsule()
.fill(TabBarColors.dropIndicator(for: appearance))
.frame(width: TabBarMetrics.dropIndicatorWidth, height: TabBarMetrics.dropIndicatorHeight)
.frame(width: TabBarMetrics.dropIndicatorWidth(zoomScale), height: TabBarMetrics.dropIndicatorHeight(zoomScale))
.offset(x: -1)
}

Expand All @@ -443,12 +444,13 @@ struct TabBarView: View {
@ViewBuilder
private var splitButtons: some View {
let tooltips = controller.configuration.appearance.splitButtonTooltips
HStack(spacing: 4) {
let splitIconSize: CGFloat = 12 * zoomScale
HStack(spacing: 4 * zoomScale) {
Button {
controller.requestNewTab(kind: "terminal", inPane: pane.id)
} label: {
Image(systemName: "terminal")
.font(.system(size: 12))
.font(.system(size: splitIconSize))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.help(tooltips.newTerminal)
Expand All @@ -457,7 +459,7 @@ struct TabBarView: View {
controller.requestNewTab(kind: "browser", inPane: pane.id)
} label: {
Image(systemName: "globe")
.font(.system(size: 12))
.font(.system(size: splitIconSize))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.help(tooltips.newBrowser)
Expand All @@ -467,7 +469,7 @@ struct TabBarView: View {
controller.splitPane(pane.id, orientation: .horizontal)
} label: {
Image(systemName: "square.split.2x1")
.font(.system(size: 12))
.font(.system(size: splitIconSize))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.help(tooltips.splitRight)
Expand All @@ -477,19 +479,19 @@ struct TabBarView: View {
controller.splitPane(pane.id, orientation: .vertical)
} label: {
Image(systemName: "square.split.1x2")
.font(.system(size: 12))
.font(.system(size: splitIconSize))
}
.buttonStyle(SplitActionButtonStyle(appearance: appearance))
.help(tooltips.splitDown)
}
.padding(.trailing, 8)
.padding(.trailing, 8 * zoomScale)
}

// MARK: - Fade Overlays

@ViewBuilder
private var fadeOverlays: some View {
let fadeWidth: CGFloat = 24
let fadeWidth: CGFloat = 24 * zoomScale

HStack(spacing: 0) {
// Left fade
Expand Down
12 changes: 7 additions & 5 deletions Sources/Bonsplit/Internal/Views/TabDragPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@ struct TabDragPreview: View {
let tab: TabItem
let appearance: BonsplitConfiguration.Appearance

@Environment(\.bonsplitZoomScale) private var zoomScale

var body: some View {
HStack(spacing: TabBarMetrics.contentSpacing) {
HStack(spacing: TabBarMetrics.contentSpacing(zoomScale)) {
if let iconName = tab.icon {
Image(systemName: iconName)
.font(.system(size: TabBarMetrics.iconSize))
.font(.system(size: TabBarMetrics.iconSize(zoomScale)))
.foregroundStyle(TabBarColors.activeText(for: appearance))
}

Text(tab.title)
.font(.system(size: TabBarMetrics.titleFontSize))
.font(.system(size: TabBarMetrics.titleFontSize(zoomScale)))
.lineLimit(1)
.foregroundStyle(TabBarColors.activeText(for: appearance))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.padding(.horizontal, 12 * zoomScale)
.padding(.vertical, 6 * zoomScale)
.background(
RoundedRectangle(cornerRadius: TabBarMetrics.tabCornerRadius, style: .continuous)
.fill(TabBarColors.activeTabBackground(for: appearance))
Expand Down
57 changes: 29 additions & 28 deletions Sources/Bonsplit/Internal/Views/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ struct TabItemView: View {
let onZoomToggle: () -> Void
let onContextAction: (TabContextAction) -> Void

@Environment(\.bonsplitZoomScale) private var zoomScale
@State private var isHovered = false
@State private var isCloseHovered = false
@State private var isZoomHovered = false
Expand All @@ -67,8 +68,8 @@ struct TabItemView: View {
var body: some View {
HStack(spacing: 0) {
// Icon + title block uses the standard spacing, but keep the close affordance tight.
HStack(spacing: TabBarMetrics.contentSpacing) {
let iconSlotSize = TabBarMetrics.iconSize
HStack(spacing: TabBarMetrics.contentSpacing(zoomScale)) {
let iconSlotSize = TabBarMetrics.iconSize(zoomScale)
let iconTint = isSelected
? TabBarColors.activeText(for: appearance)
: TabBarColors.inactiveText(for: appearance)
Expand Down Expand Up @@ -119,7 +120,7 @@ struct TabItemView: View {
.onChange(of: tab.icon) { _ in updateGlobeFallback() }

Text(tab.title)
.font(.system(size: TabBarMetrics.titleFontSize))
.font(.system(size: TabBarMetrics.titleFontSize(zoomScale)))
.lineLimit(1)
.foregroundStyle(
isSelected
Expand All @@ -133,13 +134,13 @@ struct TabItemView: View {
onZoomToggle()
} label: {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: max(8, TabBarMetrics.titleFontSize - 2), weight: .semibold))
.font(.system(size: max(8 * zoomScale, TabBarMetrics.titleFontSize(zoomScale) - 2 * zoomScale), weight: .semibold))
.foregroundStyle(
isZoomHovered
? TabBarColors.activeText(for: appearance)
: TabBarColors.inactiveText(for: appearance)
)
.frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize)
.frame(width: TabBarMetrics.closeButtonSize(zoomScale), height: TabBarMetrics.closeButtonSize(zoomScale))
.background(
Circle()
.fill(
Expand All @@ -163,13 +164,13 @@ struct TabItemView: View {
// Close button / dirty indicator / shortcut hint share the same trailing slot.
trailingAccessory
}
.padding(.horizontal, TabBarMetrics.tabHorizontalPadding)
.padding(.horizontal, TabBarMetrics.tabHorizontalPadding(zoomScale))
.offset(y: isSelected ? 0.5 : 0)
.frame(
minWidth: TabBarMetrics.tabMinWidth,
maxWidth: TabBarMetrics.tabMaxWidth,
minHeight: TabBarMetrics.tabHeight,
maxHeight: TabBarMetrics.tabHeight
minWidth: TabBarMetrics.tabMinWidth(zoomScale),
maxWidth: TabBarMetrics.tabMaxWidth(zoomScale),
minHeight: TabBarMetrics.tabHeight(zoomScale),
maxHeight: TabBarMetrics.tabHeight(zoomScale)
)
.padding(.bottom, isSelected ? 1 : 0)
.background(tabBackground.saturation(saturation))
Expand Down Expand Up @@ -201,9 +202,9 @@ struct TabItemView: View {
// `terminal.fill` reads visually heavier than most symbols at the same point size.
// Hardcode sizes to avoid cross-glyph layout shifts.
if iconName == "terminal.fill" || iconName == "terminal" || iconName == "globe" {
return max(10, TabBarMetrics.iconSize - 2.5)
return max(10 * zoomScale, TabBarMetrics.iconSize(zoomScale) - 2.5 * zoomScale)
}
return TabBarMetrics.iconSize
return TabBarMetrics.iconSize(zoomScale)
}

private var shortcutHintLabel: String? {
Expand All @@ -217,24 +218,24 @@ struct TabItemView: View {

private var shortcutHintSlotWidth: CGFloat {
guard let label = shortcutHintLabel else {
return TabBarMetrics.closeButtonSize
return TabBarMetrics.closeButtonSize(zoomScale)
}
let positiveDebugInset = max(0, CGFloat(TabControlShortcutHintDebugSettings.clamped(controlShortcutHintXOffset))) + 2
return max(TabBarMetrics.closeButtonSize, shortcutHintWidth(for: label) + positiveDebugInset)
return max(TabBarMetrics.closeButtonSize(zoomScale), shortcutHintWidth(for: label) + positiveDebugInset)
}

private func shortcutHintWidth(for label: String) -> CGFloat {
let font = NSFont.systemFont(ofSize: max(8, TabBarMetrics.titleFontSize - 2), weight: .semibold)
let font = NSFont.systemFont(ofSize: max(8 * zoomScale, TabBarMetrics.titleFontSize(zoomScale) - 2 * zoomScale), weight: .semibold)
let textWidth = (label as NSString).size(withAttributes: [.font: font]).width
return ceil(textWidth) + 8
return ceil(textWidth) + 8 * zoomScale
}

@ViewBuilder
private var trailingAccessory: some View {
ZStack(alignment: .center) {
if let shortcutHintLabel {
Text(shortcutHintLabel)
.font(.system(size: max(8, TabBarMetrics.titleFontSize - 2), weight: .semibold, design: .rounded))
.font(.system(size: max(8 * zoomScale, TabBarMetrics.titleFontSize(zoomScale) - 2 * zoomScale), weight: .semibold, design: .rounded))
.monospacedDigit()
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Expand All @@ -243,8 +244,8 @@ struct TabItemView: View {
? TabBarColors.activeText(for: appearance)
: TabBarColors.inactiveText(for: appearance)
)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.padding(.horizontal, 4 * zoomScale)
.padding(.vertical, 1 * zoomScale)
.background(
Capsule(style: .continuous)
.fill(.regularMaterial)
Expand All @@ -266,7 +267,7 @@ struct TabItemView: View {
.opacity(showsShortcutHint ? 0 : 1)
.allowsHitTesting(!showsShortcutHint)
}
.frame(width: shortcutHintSlotWidth, height: TabBarMetrics.closeButtonSize, alignment: .center)
.frame(width: shortcutHintSlotWidth, height: TabBarMetrics.closeButtonSize(zoomScale), alignment: .center)
.animation(.easeInOut(duration: 0.14), value: showsShortcutHint)
}

Expand Down Expand Up @@ -410,7 +411,7 @@ struct TabItemView: View {
if isSelected {
Rectangle()
.fill(Color.accentColor)
.frame(height: TabBarMetrics.activeIndicatorHeight)
.frame(height: TabBarMetrics.activeIndicatorHeight(zoomScale))
}

// Right border separator
Expand All @@ -434,12 +435,12 @@ struct TabItemView: View {
if tab.showsNotificationBadge {
Circle()
.fill(TabBarColors.notificationBadge(for: appearance))
.frame(width: TabBarMetrics.notificationBadgeSize, height: TabBarMetrics.notificationBadgeSize)
.frame(width: TabBarMetrics.notificationBadgeSize(zoomScale), height: TabBarMetrics.notificationBadgeSize(zoomScale))
}
if tab.isDirty {
Circle()
.fill(TabBarColors.dirtyIndicator(for: appearance))
.frame(width: TabBarMetrics.dirtyIndicatorSize, height: TabBarMetrics.dirtyIndicatorSize)
.frame(width: TabBarMetrics.dirtyIndicatorSize(zoomScale), height: TabBarMetrics.dirtyIndicatorSize(zoomScale))
.saturation(saturation)
}
}
Expand All @@ -448,9 +449,9 @@ struct TabItemView: View {
if tab.isPinned {
if isSelected || isHovered || isCloseHovered || (!tab.isDirty && !tab.showsNotificationBadge) {
Image(systemName: "pin.fill")
.font(.system(size: TabBarMetrics.closeIconSize, weight: .semibold))
.font(.system(size: TabBarMetrics.closeIconSize(zoomScale), weight: .semibold))
.foregroundStyle(TabBarColors.inactiveText(for: appearance))
.frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize)
.frame(width: TabBarMetrics.closeButtonSize(zoomScale), height: TabBarMetrics.closeButtonSize(zoomScale))
.saturation(saturation)
}
} else if isSelected || isHovered || isCloseHovered {
Expand All @@ -459,13 +460,13 @@ struct TabItemView: View {
onClose()
} label: {
Image(systemName: "xmark")
.font(.system(size: TabBarMetrics.closeIconSize, weight: .semibold))
.font(.system(size: TabBarMetrics.closeIconSize(zoomScale), weight: .semibold))
.foregroundStyle(
isCloseHovered
? TabBarColors.activeText(for: appearance)
: TabBarColors.inactiveText(for: appearance)
)
.frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize)
.frame(width: TabBarMetrics.closeButtonSize(zoomScale), height: TabBarMetrics.closeButtonSize(zoomScale))
.background(
Circle()
.fill(
Expand All @@ -482,7 +483,7 @@ struct TabItemView: View {
.saturation(saturation)
}
}
.frame(width: TabBarMetrics.closeButtonSize, height: TabBarMetrics.closeButtonSize)
.frame(width: TabBarMetrics.closeButtonSize(zoomScale), height: TabBarMetrics.closeButtonSize(zoomScale))
.animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isHovered)
.animation(.easeInOut(duration: TabBarMetrics.hoverDuration), value: isCloseHovered)
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/Bonsplit/Public/ZoomScale.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import SwiftUI

private struct BonsplitZoomScaleKey: EnvironmentKey {
static let defaultValue: CGFloat = 1.0
}

extension EnvironmentValues {
public var bonsplitZoomScale: CGFloat {
get { self[BonsplitZoomScaleKey.self] }
set { self[BonsplitZoomScaleKey.self] = newValue }
Comment on lines +8 to +10
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clamp the public zoom scale before storing it.

Every tab metric now depends on this value, so 0, negative, or non-finite input will flow straight into widths, heights, and font sizes in TabBarView, TabItemView, and TabDragPreview. Normalizing it here prevents one bad host-provided value from breaking the entire tab strip.

Proposed fix
 extension EnvironmentValues {
     public var bonsplitZoomScale: CGFloat {
         get { self[BonsplitZoomScaleKey.self] }
-        set { self[BonsplitZoomScaleKey.self] = newValue }
+        set {
+            let normalized = newValue.isFinite ? max(0.5, newValue) : 1.0
+            self[BonsplitZoomScaleKey.self] = normalized
+        }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public var bonsplitZoomScale: CGFloat {
get { self[BonsplitZoomScaleKey.self] }
set { self[BonsplitZoomScaleKey.self] = newValue }
public var bonsplitZoomScale: CGFloat {
get { self[BonsplitZoomScaleKey.self] }
set {
let normalized = newValue.isFinite ? max(0.5, newValue) : 1.0
self[BonsplitZoomScaleKey.self] = normalized
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Bonsplit/Public/ZoomScale.swift` around lines 8 - 10, The public
setter for bonsplitZoomScale must validate and normalize incoming values before
storing them in BonsplitZoomScaleKey.self: ensure the value is finite and
positive, then clamp it to a safe range (e.g., min 0.5, max 3.0) and store that
clamped value; if the input is non-finite or <= 0, replace it with the minimum
sane value. Update the setter for bonsplitZoomScale to perform this check/clamp
so TabBarView, TabItemView, and TabDragPreview never receive 0, negative, or
infinite scales.

}
}