diff --git a/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift b/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift index 5e7d657..c407e6f 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift @@ -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 diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 6b2e107..efe1af7 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -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 @@ -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, @@ -169,7 +170,7 @@ struct TabBarView: View { } } } - .frame(height: TabBarMetrics.barHeight) + .frame(height: TabBarMetrics.barHeight(zoomScale)) .overlay(fadeOverlays) } @@ -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) @@ -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, @@ -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) } @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift index 7987397..3601722 100644 --- a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift +++ b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift @@ -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)) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index bc23a33..8254a68 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -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 @@ -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) @@ -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 @@ -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( @@ -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)) @@ -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? { @@ -217,16 +218,16 @@ 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 @@ -234,7 +235,7 @@ struct TabItemView: 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) @@ -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) @@ -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) } @@ -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 @@ -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) } } @@ -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 { @@ -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( @@ -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) } diff --git a/Sources/Bonsplit/Public/ZoomScale.swift b/Sources/Bonsplit/Public/ZoomScale.swift new file mode 100644 index 0000000..7c7f363 --- /dev/null +++ b/Sources/Bonsplit/Public/ZoomScale.swift @@ -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 } + } +}