diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d15273d7fe..edeb43f48b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -11389,14 +11389,14 @@ struct VerticalTabsSidebar: View { isSelected: Bool, dropRows: [ExtensionSidebarBrowserStackDropRow] ) -> some View { - let targetRowHeight: CGFloat = 54 + let targetRowHeight = ExtensionBrowserStackSidebarMetrics.tileHeight return Button { selectExtensionSidebarWorkspace(row.workspaceId) } label: { - extensionBrowserStackIcon(row.leadingIcon, size: 28) + extensionBrowserStackIcon(row.leadingIcon, size: ExtensionBrowserStackSidebarMetrics.iconSize) .frame(maxWidth: .infinity) - .frame(height: 54) + .frame(height: targetRowHeight) .background( RoundedRectangle(cornerRadius: 13, style: .continuous) .fill( @@ -11461,13 +11461,16 @@ struct VerticalTabsSidebar: View { isSelected: Bool, dropRows: [ExtensionSidebarBrowserStackDropRow] ) -> some View { - let targetRowHeight: CGFloat = compact ? 34 : 38 + let targetRowHeight = ExtensionBrowserStackSidebarMetrics.rowHeight(compact: compact) + let rowHorizontalPadding = ExtensionBrowserStackSidebarMetrics.rowHorizontalPadding(compact: compact) + let rowVerticalPadding = ExtensionBrowserStackSidebarMetrics.rowVerticalPadding(compact: compact) + let rowCornerRadius = ExtensionBrowserStackSidebarMetrics.rowCornerRadius(compact: compact) return Button { selectExtensionSidebarWorkspace(row.workspaceId) } label: { HStack(spacing: 9) { - extensionBrowserStackIcon(row.leadingIcon, size: compact ? 22 : 24) + extensionBrowserStackIcon(row.leadingIcon, size: ExtensionBrowserStackSidebarMetrics.iconSize) Text(row.title) .font(.system(size: compact ? 12.5 : 13, weight: .medium)) .foregroundColor(isSelected ? .primary : .primary.opacity(0.82)) @@ -11481,14 +11484,14 @@ struct VerticalTabsSidebar: View { .lineLimit(1) } } - .padding(.horizontal, compact ? 7 : 10) - .padding(.vertical, compact ? 6 : 7) + .padding(.horizontal, rowHorizontalPadding) + .padding(.vertical, rowVerticalPadding) .background( - RoundedRectangle(cornerRadius: compact ? 8 : 10, style: .continuous) + RoundedRectangle(cornerRadius: rowCornerRadius, style: .continuous) .fill(isSelected ? Color.primary.opacity(0.12) : Color.clear) ) .overlay( - RoundedRectangle(cornerRadius: compact ? 8 : 10, style: .continuous) + RoundedRectangle(cornerRadius: rowCornerRadius, style: .continuous) .stroke(isSelected ? cmuxAccentColor().opacity(0.55) : Color.clear, lineWidth: 1) ) .contentShape(Rectangle()) @@ -11631,6 +11634,32 @@ struct VerticalTabsSidebar: View { return snapshotsById } + private enum ExtensionBrowserStackSidebarMetrics { + static let iconSize: CGFloat = 28 + static let tileHeight: CGFloat = 54 + + private static let regularRowHeight: CGFloat = 38 + private static let compactRowHeight: CGFloat = 34 + + static func rowHeight(compact: Bool) -> CGFloat { + compact ? compactRowHeight : regularRowHeight + } + + static func rowHorizontalPadding(compact: Bool) -> CGFloat { + compact ? 7 : 10 + } + + static func rowVerticalPadding(compact: Bool) -> CGFloat { + let height = rowHeight(compact: compact) + assert(iconSize <= height, "iconSize (\(iconSize)) must not exceed rowHeight (\(height))") + return max(0, (height - iconSize) / 2) + } + + static func rowCornerRadius(compact: Bool) -> CGFloat { + compact ? 8 : 10 + } + } + private func extensionBrowserStackIcon( _ icon: CmuxExtensionSidebarRenderIcon?, size: CGFloat diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 7c4557352e..4cb8f882c6 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -5,9 +5,8 @@ enum HeaderChromeIconStyle { static let hoveredOpacity = 0.96 static let pressedOpacity = 1.0 static let disabledOpacity = 0.34 - static let weight: Font.Weight = .regular + static let weight: Font.Weight = .semibold static let foregroundColor = Color(nsColor: .secondaryLabelColor) - static let sidebarGlyphStrokeWidth: CGFloat = 1 static func iconFrameSize(forIconSize iconSize: CGFloat) -> CGFloat { HeaderChromeControlMetrics.iconFrameSize(forIconSize: iconSize) diff --git a/Sources/Update/MinimalModeSidebarControls.swift b/Sources/Update/MinimalModeSidebarControls.swift index 7d8402327b..bcfedd0328 100644 --- a/Sources/Update/MinimalModeSidebarControls.swift +++ b/Sources/Update/MinimalModeSidebarControls.swift @@ -4,6 +4,7 @@ import SwiftUI struct MinimalModeSidebarControlActionProxyView: NSViewRepresentable { let config: TitlebarControlsStyleConfig + var buttonCount = TitlebarControlsHitRegions.sidebarChromeButtonCount var isEnabled = true var requiresRevealedState = false let onAction: (MinimalModeSidebarControlActionSlot, NSView, NSPoint) -> Void @@ -20,6 +21,7 @@ struct MinimalModeSidebarControlActionProxyView: NSViewRepresentable { private func configure(_ view: MinimalModeSidebarControlActionView) { view.config = config + view.buttonCount = buttonCount view.isEnabled = isEnabled view.requiresRevealedState = requiresRevealedState view.onAction = onAction @@ -28,14 +30,19 @@ struct MinimalModeSidebarControlActionProxyView: NSViewRepresentable { enum TitlebarControlsHitRegions { static let outerLeadingPadding: CGFloat = HeaderChromeControlMetrics.titlebarControlsLeadingPadding - static let buttonCount = MinimalModeSidebarControlActionSlot.allCases.count + static let sidebarChromeButtonCount = TitlebarShortcutHintActionSlot.sidebarChromeSlots.count + static let allTitlebarButtonCount = MinimalModeSidebarControlActionSlot.allCases.count - static func buttonXRanges(config: TitlebarControlsStyleConfig) -> [ClosedRange] { + static func buttonXRanges( + config: TitlebarControlsStyleConfig, + buttonCount: Int = sidebarChromeButtonCount + ) -> [ClosedRange] { var ranges: [ClosedRange] = [] - ranges.reserveCapacity(buttonCount) + let clampedButtonCount = max(0, min(buttonCount, allTitlebarButtonCount)) + ranges.reserveCapacity(clampedButtonCount) var minX = outerLeadingPadding + config.groupPadding.leading - for _ in 0.. MinimalModeSidebarControlActionSlot? { - for (index, range) in buttonXRanges(config: config).enumerated() where range.contains(point.x) { + for (index, range) in buttonXRanges(config: config, buttonCount: buttonCount).enumerated() + where range.contains(point.x) { return MinimalModeSidebarControlActionSlot(rawValue: index) } return nil } - static func pointFallsInButtonColumn(_ point: NSPoint, config: TitlebarControlsStyleConfig) -> Bool { - sidebarActionSlot(at: point, config: config) != nil + static func pointFallsInButtonColumn( + _ point: NSPoint, + config: TitlebarControlsStyleConfig, + buttonCount: Int = sidebarChromeButtonCount + ) -> Bool { + sidebarActionSlot(at: point, config: config, buttonCount: buttonCount) != nil } } @@ -64,6 +77,10 @@ final class MinimalModeSidebarControlActionView: NSView { { didSet { needsLayout = true } } + var buttonCount = TitlebarControlsHitRegions.sidebarChromeButtonCount + { + didSet { needsLayout = true } + } var isEnabled = true { didSet { syncButtons() } @@ -139,7 +156,7 @@ final class MinimalModeSidebarControlActionView: NSView { override func accessibilityChildren() -> [Any]? { guard isRevealed || !requiresRevealedState else { return [] } - return MinimalModeSidebarControlActionSlot.allCases.compactMap { buttons[$0] } + return visibleSlots.compactMap { buttons[$0] } } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { @@ -153,7 +170,11 @@ final class MinimalModeSidebarControlActionView: NSView { return nil } guard bounds.contains(point) else { return nil } - guard let slot = TitlebarControlsHitRegions.sidebarActionSlot(at: point, config: config) else { + guard let slot = TitlebarControlsHitRegions.sidebarActionSlot( + at: point, + config: config, + buttonCount: buttonCount + ) else { return nil } if NSApp.currentEvent?.type == .rightMouseDown, !slot.acceptsContextMenu { @@ -175,7 +196,11 @@ final class MinimalModeSidebarControlActionView: NSView { override func mouseDown(with event: NSEvent) { let localPoint = convert(event.locationInWindow, from: nil) - guard let slot = TitlebarControlsHitRegions.sidebarActionSlot(at: localPoint, config: config) else { + guard let slot = TitlebarControlsHitRegions.sidebarActionSlot( + at: localPoint, + config: config, + buttonCount: buttonCount + ) else { super.mouseDown(with: event) return } @@ -188,7 +213,11 @@ final class MinimalModeSidebarControlActionView: NSView { override func rightMouseDown(with event: NSEvent) { let localPoint = convert(event.locationInWindow, from: nil) - guard let slot = TitlebarControlsHitRegions.sidebarActionSlot(at: localPoint, config: config), + guard let slot = TitlebarControlsHitRegions.sidebarActionSlot( + at: localPoint, + config: config, + buttonCount: buttonCount + ), shouldAcceptAction(at: localPoint) else { super.rightMouseDown(with: event) return @@ -209,7 +238,10 @@ final class MinimalModeSidebarControlActionView: NSView { override func layout() { super.layout() - let ranges = TitlebarControlsHitRegions.buttonXRanges(config: config) + for button in buttons.values { + button.frame = .zero + } + let ranges = TitlebarControlsHitRegions.buttonXRanges(config: config, buttonCount: buttonCount) for (index, range) in ranges.enumerated() { guard let slot = MinimalModeSidebarControlActionSlot(rawValue: index), let button = buttons[slot] else { continue } @@ -223,6 +255,12 @@ final class MinimalModeSidebarControlActionView: NSView { syncButtons() } + private var visibleSlots: ArraySlice { + MinimalModeSidebarControlActionSlot.allCases.prefix( + max(0, min(buttonCount, TitlebarControlsHitRegions.allTitlebarButtonCount)) + ) + } + @objc private func buttonPressed(_ sender: NSButton) { guard let sender = sender as? MinimalModeSidebarControlButton else { return } performButtonAction(sender) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index e98920220e..463d93e3c8 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -35,7 +35,7 @@ enum TitlebarControlsStyle: Int, CaseIterable, Identifiable { switch self { case .classic: return TitlebarControlsStyleConfig( - spacing: 6, + spacing: 10, iconSize: HeaderChromeControlMetrics.iconSize, buttonSize: HeaderChromeControlMetrics.buttonSize, badgeSize: 12, @@ -48,54 +48,54 @@ enum TitlebarControlsStyle: Int, CaseIterable, Identifiable { ) case .compact: return TitlebarControlsStyleConfig( - spacing: 5, - iconSize: 11, - buttonSize: 18, + spacing: 6, + iconSize: 13, + buttonSize: 20, badgeSize: 11, badgeOffset: CGSize(width: 3, height: -3), groupBackground: false, groupPadding: EdgeInsets(), buttonBackground: false, - buttonCornerRadius: 5, + buttonCornerRadius: 6, hoverBackground: false ) case .roomy: return TitlebarControlsStyleConfig( - spacing: 7, - iconSize: 13, - buttonSize: 22, + spacing: 14, + iconSize: 16, + buttonSize: 28, badgeSize: 13, badgeOffset: CGSize(width: 3, height: -3), groupBackground: false, groupPadding: EdgeInsets(), buttonBackground: false, - buttonCornerRadius: 7, + buttonCornerRadius: 10, hoverBackground: false ) case .pillGroup: return TitlebarControlsStyleConfig( - spacing: 5, - iconSize: 12, - buttonSize: 20, + spacing: 8, + iconSize: 14, + buttonSize: 24, badgeSize: 12, badgeOffset: CGSize(width: 3, height: -3), groupBackground: false, - groupPadding: EdgeInsets(top: 1, leading: 3, bottom: 1, trailing: 3), + groupPadding: EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4), buttonBackground: false, - buttonCornerRadius: 6, + buttonCornerRadius: 8, hoverBackground: true ) case .softButtons: return TitlebarControlsStyleConfig( - spacing: 6, - iconSize: 12, - buttonSize: 21, + spacing: 8, + iconSize: 15, + buttonSize: 26, badgeSize: 12, badgeOffset: CGSize(width: 3, height: -3), groupBackground: false, groupPadding: EdgeInsets(), buttonBackground: true, - buttonCornerRadius: 6, + buttonCornerRadius: 8, hoverBackground: false ) } @@ -115,6 +115,23 @@ struct TitlebarControlsStyleConfig { let hoverBackground: Bool } +extension TitlebarControlsStyleConfig { + func replacing(spacing: CGFloat, iconSize: CGFloat, buttonSize: CGFloat) -> TitlebarControlsStyleConfig { + TitlebarControlsStyleConfig( + spacing: spacing, + iconSize: iconSize, + buttonSize: buttonSize, + badgeSize: badgeSize, + badgeOffset: badgeOffset, + groupBackground: groupBackground, + groupPadding: groupPadding, + buttonBackground: buttonBackground, + buttonCornerRadius: buttonCornerRadius, + hoverBackground: hoverBackground + ) + } +} + enum TitlebarControlsVisualMetrics { static let verticalLift: CGFloat = 0 @@ -425,11 +442,13 @@ func titlebarHintPillWidth(for shortcut: StoredShortcut, config: TitlebarControl /// are currently visible. Returns 0 when no slot would show a hint. func titlebarHintLayoutRightmostExtent( config: TitlebarControlsStyleConfig, + buttonCount: Int = TitlebarShortcutHintActionSlot.allCases.count, titlebarShortcutHintXOffset: Double = ShortcutHintDebugSettings.defaultTitlebarHintX ) -> CGFloat { let xOffset = CGFloat(ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)) + let clampedButtonCount = max(0, min(buttonCount, TitlebarShortcutHintActionSlot.allCases.count)) var intervals: [ClosedRange] = [] - for slot in TitlebarShortcutHintActionSlot.allCases { + for slot in TitlebarShortcutHintActionSlot.allCases.prefix(clampedButtonCount) { let shortcut = KeyboardShortcutSettings.shortcut(for: slot.action) guard !shortcut.isUnbound, shortcut.command else { continue } let width = titlebarHintPillWidth(for: shortcut, config: config) @@ -461,6 +480,12 @@ enum TitlebarShortcutHintActionSlot: Int, CaseIterable { case focusHistoryBack case focusHistoryForward + static let sidebarChromeSlots: [TitlebarShortcutHintActionSlot] = [ + .toggleSidebar, + .showNotifications, + .newTab + ] + var action: KeyboardShortcutSettings.Action { switch self { case .toggleSidebar: @@ -494,9 +519,55 @@ enum TitlebarControlsLayoutMetrics { } static func buttonRowWidth(config: TitlebarControlsStyleConfig) -> CGFloat { - let buttonCount = CGFloat(TitlebarShortcutHintActionSlot.allCases.count) - let gapCount = max(0, buttonCount - 1) - return (buttonCount * config.buttonSize) + (gapCount * config.spacing) + buttonRowWidth(config: config, buttonCount: TitlebarShortcutHintActionSlot.allCases.count) + } + + static func buttonRowWidth(config: TitlebarControlsStyleConfig, buttonCount: Int) -> CGFloat { + let clampedButtonCount = CGFloat(max(0, buttonCount)) + let gapCount = max(0, clampedButtonCount - 1) + return (clampedButtonCount * config.buttonSize) + (gapCount * config.spacing) + } + + static func fittingConfig( + _ config: TitlebarControlsStyleConfig, + buttonCount: Int, + maxWidth: CGFloat, + titlebarShortcutHintXOffset: Double = ShortcutHintDebugSettings.defaultTitlebarHintX, + reservesShortcutHintOverflow: Bool = false + ) -> TitlebarControlsStyleConfig { + let clampedButtonCount = max(0, buttonCount) + guard clampedButtonCount > 0, maxWidth > 0 else { + return config + } + + let shortcutHintOverflow = reservesShortcutHintOverflow + ? hintTrailingInset(titlebarShortcutHintXOffset: titlebarShortcutHintXOffset) + : 0 + let nonRowWidth = outerLeadingPadding + + config.groupPadding.leading + + config.groupPadding.trailing + + shortcutHintOverflow + let availableRowWidth = max(0, maxWidth - nonRowWidth) + let buttonCountWidth = CGFloat(clampedButtonCount) + let gapCount = max(0, buttonCountWidth - 1) + + var buttonSize = config.buttonSize + var iconSize = config.iconSize + if buttonSize * buttonCountWidth > availableRowWidth { + buttonSize = availableRowWidth / buttonCountWidth + iconSize = min(iconSize, max(0, buttonSize - 2)) + } + + var spacing = config.spacing + if gapCount > 0 { + let availableGapWidth = max(0, availableRowWidth - (buttonSize * buttonCountWidth)) + spacing = min(spacing, availableGapWidth / gapCount) + } + + guard spacing != config.spacing || buttonSize != config.buttonSize || iconSize != config.iconSize else { + return config + } + return config.replacing(spacing: spacing, iconSize: iconSize, buttonSize: buttonSize) } static func buttonCenterX( @@ -521,26 +592,39 @@ enum TitlebarControlsLayoutMetrics { static func contentSize( config: TitlebarControlsStyleConfig, - titlebarShortcutHintXOffset: Double = ShortcutHintDebugSettings.defaultTitlebarHintX + buttonCount: Int = TitlebarShortcutHintActionSlot.allCases.count, + titlebarShortcutHintXOffset: Double = ShortcutHintDebugSettings.defaultTitlebarHintX, + reservesShortcutHintOverflow: Bool = false ) -> NSSize { - // Two width requirements; reserve the larger so neither the buttons nor the - // shortcut hints are clipped by the accessory's allocated frame. - let buttonReservation = outerLeadingPadding + // Base button-lane width for the requested slot count. + let buttonRow = outerLeadingPadding + config.groupPadding.leading - + buttonRowWidth(config: config) + + buttonRowWidth(config: config, buttonCount: buttonCount) + config.groupPadding.trailing - + hintTrailingInset(titlebarShortcutHintXOffset: titlebarShortcutHintXOffset) - // Drive the reservation from the planner's actual rightmost hint edge so the - // overlap-shift the planner applies (which the fixed inset above ignores) is - // always covered. This is what prevents the rightmost pill from clipping. - let hintReservation = hintLeadingPadding - + titlebarHintLayoutRightmostExtent( - config: config, - titlebarShortcutHintXOffset: titlebarShortcutHintXOffset - ) - + hintShadowMargin + let width: CGFloat + if reservesShortcutHintOverflow { + // Two width requirements; reserve the larger so neither the buttons nor the + // shortcut hints are clipped by the accessory's allocated frame. + let buttonReservation = buttonRow + + hintTrailingInset(titlebarShortcutHintXOffset: titlebarShortcutHintXOffset) + // Drive the reservation from the planner's actual rightmost hint edge so the + // overlap-shift the planner applies (which the fixed inset above ignores) is + // always covered. This is what prevents the rightmost pill from clipping. The + // extent is bounded to the active slot count so the narrower sidebar chrome + // (three buttons) does not reserve for the full titlebar's focus-history hints. + let hintReservation = hintLeadingPadding + + titlebarHintLayoutRightmostExtent( + config: config, + buttonCount: buttonCount, + titlebarShortcutHintXOffset: titlebarShortcutHintXOffset + ) + + hintShadowMargin + width = max(buttonReservation, hintReservation) + } else { + width = buttonRow + } return NSSize( - width: max(buttonReservation, hintReservation), + width: width, height: max( WindowChromeMetrics.appTitlebarHeight, config.groupPadding.top + config.buttonSize + config.groupPadding.bottom @@ -581,13 +665,21 @@ enum TitlebarControlsLayoutMetrics { } } +func titlebarControlsSidebarChromeConfig(for style: TitlebarControlsStyle) -> TitlebarControlsStyleConfig { + TitlebarControlsLayoutMetrics.fittingConfig( + style.config, + buttonCount: TitlebarShortcutHintActionSlot.sidebarChromeSlots.count, + maxWidth: MinimalModeSidebarTitlebarControlsMetrics.hostWidth, + reservesShortcutHintOverflow: true + ) +} + private enum TitlebarControlIconStyle { static let opacity = HeaderChromeIconStyle.opacity static let hoveredOpacity = HeaderChromeIconStyle.hoveredOpacity static let pressedOpacity = HeaderChromeIconStyle.pressedOpacity static let weight = HeaderChromeIconStyle.weight static let foregroundColor = HeaderChromeIconStyle.foregroundColor - static let sidebarGlyphStrokeWidth = HeaderChromeIconStyle.sidebarGlyphStrokeWidth static func iconFrameSize(for config: TitlebarControlsStyleConfig) -> CGFloat { HeaderChromeIconStyle.iconFrameSize(forIconSize: config.iconSize) @@ -816,6 +908,8 @@ struct TitlebarControlsView: View { let onFocusHistoryBack: () -> Void let onFocusHistoryForward: () -> Void let visibilityMode: TitlebarControlsVisibilityMode + var actionSlots = TitlebarShortcutHintActionSlot.allCases + var styleConfigOverride: TitlebarControlsStyleConfig? @ObservedObject private var popoverVisibilityState = NotificationsPopoverVisibilityState.shared @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue @State private var shortcutRefreshTick = 0 @@ -856,15 +950,20 @@ struct TitlebarControlsView: View { let _ = shortcutRefreshTick let _ = appearanceRefreshTick let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic - let config = style.config + let config = styleConfigOverride ?? style.config + let reservesShortcutHintOverflow = shouldShowTitlebarShortcutHints let contentSize = TitlebarControlsLayoutMetrics.contentSize( config: config, - titlebarShortcutHintXOffset: titlebarShortcutHintXOffset + buttonCount: actionSlots.count, + titlebarShortcutHintXOffset: titlebarShortcutHintXOffset, + reservesShortcutHintOverflow: reservesShortcutHintOverflow ) let foregroundColor = Color(nsColor: titlebarControlForegroundNSColor(opacity: 1.0)) controlsGroup(config: config, foregroundColor: foregroundColor) + .padding(.top, -1) + .padding(.bottom, 1) .padding(.leading, TitlebarControlsLayoutMetrics.hintLeadingPadding) - .padding(.trailing, titlebarHintTrailingInset) + .padding(.trailing, reservesShortcutHintOverflow ? titlebarHintTrailingInset : 0) .frame(width: contentSize.width, height: contentSize.height, alignment: .leading) .fixedSize() .contentShape(Rectangle()) @@ -928,106 +1027,119 @@ struct TitlebarControlsView: View { let hintLayoutItems = titlebarHintLayoutItems(config: config) let focusHistoryAvailability = focusHistoryNavigationAvailabilitySnapshot let content = HStack(spacing: config.spacing) { - TitlebarControlButton( - config: config, - foregroundColor: foregroundColor, - accessibilityIdentifier: "titlebarControl.toggleSidebar", - accessibilityLabel: String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"), - action: { - #if DEBUG - cmuxDebugLog("titlebar.toggleSidebar") - #endif - onToggleSidebar() - }, - rightClickAction: { anchorView, event in - CmuxExtensionSidebarSelection.showMenu(anchorView: anchorView, event: event) - }) { - sidebarIconLabel(config: config, iconGeometryKeyPrefix: "titlebarControl_toggleSidebarIcon") - } - .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) - - TitlebarControlButton( - config: config, - foregroundColor: foregroundColor, - accessibilityIdentifier: "titlebarControl.showNotifications", - accessibilityLabel: String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"), - action: { - #if DEBUG - cmuxDebugLog("titlebar.notifications") - #endif - onToggleNotifications() - }) { - ZStack(alignment: .topTrailing) { + if actionSlots.contains(.toggleSidebar) { + TitlebarControlButton( + config: config, + foregroundColor: foregroundColor, + accessibilityIdentifier: "titlebarControl.toggleSidebar", + accessibilityLabel: String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"), + action: { + #if DEBUG + cmuxDebugLog("titlebar.toggleSidebar") + #endif + onToggleSidebar() + }, + rightClickAction: { anchorView, event in + CmuxExtensionSidebarSelection.showMenu(anchorView: anchorView, event: event) + }) { iconLabel( - systemName: "bell", + systemName: "sidebar.left", config: config, - iconGeometryKeyPrefix: "titlebarControl_showNotificationsIcon" + iconGeometryKeyPrefix: "titlebarControl_toggleSidebarIcon" ) - - if notificationStore.unreadCount > 0 { - Text("\(min(notificationStore.unreadCount, 99))") - .font(.system(size: titlebarNotificationBadgeFontSize(for: config), weight: .semibold)) - .foregroundColor(.white) - .frame(width: config.badgeSize, height: config.badgeSize) - .background( - Circle().fill(cmuxAccentColor()) - ) - .offset(x: config.badgeOffset.width, y: config.badgeOffset.height) - } } - .frame(width: config.buttonSize, height: config.buttonSize) - } - .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) - .safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications"))) - - TitlebarControlButton( - config: config, - foregroundColor: foregroundColor, - accessibilityIdentifier: "titlebarControl.newTab", - accessibilityLabel: String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"), - action: { - #if DEBUG - cmuxDebugLog("titlebar.newTab") - #endif - onNewTab() - }, - rightClickAction: { anchorView, event in - _ = AppDelegate.shared?.showNewWorkspaceContextMenu(anchorView: anchorView, event: event) + .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) + } + + if actionSlots.contains(.showNotifications) { + TitlebarControlButton( + config: config, + foregroundColor: foregroundColor, + accessibilityIdentifier: "titlebarControl.showNotifications", + accessibilityLabel: String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"), + action: { + #if DEBUG + cmuxDebugLog("titlebar.notifications") + #endif + onToggleNotifications() }) { - iconLabel(systemName: "plus", config: config, iconGeometryKeyPrefix: "titlebarControl_newTabIcon") - } - .safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) + ZStack(alignment: .topTrailing) { + iconLabel( + systemName: "bell", + config: config, + iconGeometryKeyPrefix: "titlebarControl_showNotificationsIcon" + ) - TitlebarControlButton( - config: config, - foregroundColor: foregroundColor, - accessibilityIdentifier: "titlebarControl.focusHistoryBack", - accessibilityLabel: String(localized: "menu.history.focusBack", defaultValue: "Focus Back"), - action: onFocusHistoryBack, - isEnabled: focusHistoryAvailability.canNavigateBack, - rightClickAction: { anchorView, event in - _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: anchorView, event: event, direction: .back) + if notificationStore.unreadCount > 0 { + Text("\(min(notificationStore.unreadCount, 99))") + .font(.system(size: titlebarNotificationBadgeFontSize(for: config), weight: .semibold)) + .foregroundColor(.white) + .frame(width: config.badgeSize, height: config.badgeSize) + .background( + Circle().fill(cmuxAccentColor()) + ) + .offset(x: config.badgeOffset.width, y: config.badgeOffset.height) + } + } + .frame(width: config.buttonSize, height: config.buttonSize) } - ) { - iconLabel(systemName: "arrow.left", config: config, iconGeometryKeyPrefix: "titlebarControl_focusHistoryBackIcon") - } - .safeHelp(KeyboardShortcutSettings.Action.focusHistoryBack.tooltip(String(localized: "menu.history.focusBack", defaultValue: "Focus Back"))) - - TitlebarControlButton( - config: config, - foregroundColor: foregroundColor, - accessibilityIdentifier: "titlebarControl.focusHistoryForward", - accessibilityLabel: String(localized: "menu.history.focusForward", defaultValue: "Focus Forward"), - action: onFocusHistoryForward, - isEnabled: focusHistoryAvailability.canNavigateForward, - rightClickAction: { anchorView, event in - _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: anchorView, event: event, direction: .forward) + .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) + .safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications"))) + } + + if actionSlots.contains(.newTab) { + TitlebarControlButton( + config: config, + foregroundColor: foregroundColor, + accessibilityIdentifier: "titlebarControl.newTab", + accessibilityLabel: String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"), + action: { + #if DEBUG + cmuxDebugLog("titlebar.newTab") + #endif + onNewTab() + }, + rightClickAction: { anchorView, event in + _ = AppDelegate.shared?.showNewWorkspaceContextMenu(anchorView: anchorView, event: event) + }) { + iconLabel(systemName: "plus", config: config, iconGeometryKeyPrefix: "titlebarControl_newTabIcon") } - ) { - iconLabel(systemName: "arrow.right", config: config, iconGeometryKeyPrefix: "titlebarControl_focusHistoryForwardIcon") + .safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) + } + + if actionSlots.contains(.focusHistoryBack) { + TitlebarControlButton( + config: config, + foregroundColor: foregroundColor, + accessibilityIdentifier: "titlebarControl.focusHistoryBack", + accessibilityLabel: String(localized: "menu.history.focusBack", defaultValue: "Focus Back"), + action: onFocusHistoryBack, + isEnabled: focusHistoryAvailability.canNavigateBack, + rightClickAction: { anchorView, event in + _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: anchorView, event: event, direction: .back) + } + ) { + iconLabel(systemName: "arrow.left", config: config, iconGeometryKeyPrefix: "titlebarControl_focusHistoryBackIcon") + } + .safeHelp(KeyboardShortcutSettings.Action.focusHistoryBack.tooltip(String(localized: "menu.history.focusBack", defaultValue: "Focus Back"))) + } + + if actionSlots.contains(.focusHistoryForward) { + TitlebarControlButton( + config: config, + foregroundColor: foregroundColor, + accessibilityIdentifier: "titlebarControl.focusHistoryForward", + accessibilityLabel: String(localized: "menu.history.focusForward", defaultValue: "Focus Forward"), + action: onFocusHistoryForward, + isEnabled: focusHistoryAvailability.canNavigateForward, + rightClickAction: { anchorView, event in + _ = AppDelegate.shared?.showFocusHistoryContextMenu(anchorView: anchorView, event: event, direction: .forward) + } + ) { + iconLabel(systemName: "arrow.right", config: config, iconGeometryKeyPrefix: "titlebarControl_focusHistoryForwardIcon") + } + .safeHelp(KeyboardShortcutSettings.Action.focusHistoryForward.tooltip(String(localized: "menu.history.focusForward", defaultValue: "Focus Forward"))) } - .safeHelp(KeyboardShortcutSettings.Action.focusHistoryForward.tooltip(String(localized: "menu.history.focusForward", defaultValue: "Focus Forward"))) - } let paddedContent = content.padding(config.groupPadding) @@ -1094,7 +1206,7 @@ struct TitlebarControlsView: View { ) -> [(action: KeyboardShortcutSettings.Action, shortcut: StoredShortcut, width: CGFloat, interval: ClosedRange)] { guard shouldShowTitlebarShortcutHints else { return [] } - return TitlebarShortcutHintActionSlot.allCases.compactMap { slot in + return actionSlots.compactMap { slot in let shortcut = KeyboardShortcutSettings.shortcut(for: slot.action) guard titlebarShortcutHintShouldShow( shortcut: shortcut, @@ -1165,16 +1277,6 @@ struct TitlebarControlsView: View { } } - @ViewBuilder - private func sidebarIconLabel( - config: TitlebarControlsStyleConfig, - iconGeometryKeyPrefix: String? = nil - ) -> some View { - titlebarIconChrome(config: config, iconGeometryKeyPrefix: iconGeometryKeyPrefix) { - TitlebarSidebarGlyph(iconSize: config.iconSize) - } - } - @ViewBuilder private func titlebarIconChrome( config: TitlebarControlsStyleConfig, @@ -1190,60 +1292,36 @@ struct TitlebarControlsView: View { } } -private struct TitlebarSidebarGlyph: View { - let iconSize: CGFloat - - var body: some View { - TitlebarSidebarGlyphShape() - .stroke( - style: StrokeStyle( - lineWidth: TitlebarControlIconStyle.sidebarGlyphStrokeWidth, - lineCap: .round, - lineJoin: .round - ) - ) - .frame(width: max(13, iconSize + 2), height: max(11, iconSize - 1)) - } -} - -private struct TitlebarSidebarGlyphShape: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - let insetRect = rect.insetBy(dx: 0.5, dy: 0.5) - path.addRoundedRect( - in: insetRect, - cornerSize: CGSize(width: 2, height: 2) - ) - - let dividerX = insetRect.minX + insetRect.width * 0.36 - path.move(to: CGPoint(x: dividerX, y: insetRect.minY + 1.5)) - path.addLine(to: CGPoint(x: dividerX, y: insetRect.maxY - 1.5)) - return path - } -} - private struct TitlebarControlsGapDragView: NSViewRepresentable { let config: TitlebarControlsStyleConfig + let buttonCount: Int func makeNSView(context: Context) -> GapDragView { let view = GapDragView() view.config = config + view.buttonCount = buttonCount return view } func updateNSView(_ nsView: GapDragView, context: Context) { nsView.config = config + nsView.buttonCount = buttonCount } final class GapDragView: NSView { var config = TitlebarControlsStyle.classic.config + var buttonCount = TitlebarControlsHitRegions.sidebarChromeButtonCount override var mouseDownCanMoveWindow: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { guard NSApp.currentEvent?.type == .leftMouseDown else { return nil } guard bounds.contains(point) else { return nil } - guard !TitlebarControlsHitRegions.pointFallsInButtonColumn(point, config: config) else { + guard !TitlebarControlsHitRegions.pointFallsInButtonColumn( + point, + config: config, + buttonCount: buttonCount + ) else { return nil } return self @@ -1272,20 +1350,24 @@ private struct TitlebarControlsGapDragView: NSViewRepresentable { private struct MinimalModeTitlebarButtonHitRegionView: NSViewRepresentable { let config: TitlebarControlsStyleConfig + let buttonCount: Int func makeNSView(context: Context) -> ButtonHitRegionView { let view = ButtonHitRegionView() view.config = config + view.buttonCount = buttonCount return view } func updateNSView(_ nsView: ButtonHitRegionView, context: Context) { nsView.config = config + nsView.buttonCount = buttonCount MinimalModeTitlebarControlHitRegionRegistry.register(nsView) } final class ButtonHitRegionView: NSView, MinimalModeSidebarControlActionHitRegionProviding { var config = TitlebarControlsStyle.classic.config + var buttonCount = TitlebarControlsHitRegions.sidebarChromeButtonCount override func viewDidMoveToWindow() { super.viewDidMoveToWindow() @@ -1303,7 +1385,11 @@ private struct MinimalModeTitlebarButtonHitRegionView: NSViewRepresentable { } func minimalModeSidebarControlActionSlot(localPoint: NSPoint) -> MinimalModeSidebarControlActionSlot? { - TitlebarControlsHitRegions.sidebarActionSlot(at: localPoint, config: config) + TitlebarControlsHitRegions.sidebarActionSlot( + at: localPoint, + config: config, + buttonCount: buttonCount + ) } deinit { @@ -1330,8 +1416,13 @@ struct HiddenTitlebarSidebarControlsView: View { isHoveringHost || isHoveringWindowChrome || popoverVisibilityState.isShown(in: hostWindowNumber) } + private var sidebarChromeButtonCount: Int { + TitlebarShortcutHintActionSlot.sidebarChromeSlots.count + } + var body: some View { let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic + let config = titlebarControlsSidebarChromeConfig(for: style) ZStack(alignment: .leading) { WindowAccessor { window in @@ -1373,7 +1464,9 @@ struct HiddenTitlebarSidebarControlsView: View { onNewTab: onNewTab, onFocusHistoryBack: onFocusHistoryBack, onFocusHistoryForward: onFocusHistoryForward, - visibilityMode: .alwaysVisible + visibilityMode: .alwaysVisible, + actionSlots: TitlebarShortcutHintActionSlot.sidebarChromeSlots, + styleConfigOverride: config ) .frame( width: MinimalModeSidebarTitlebarControlsMetrics.hostWidth, @@ -1385,14 +1478,15 @@ struct HiddenTitlebarSidebarControlsView: View { .accessibilityHidden(true) .animation(.easeInOut(duration: 0.14), value: shouldPinControls) - TitlebarControlsGapDragView(config: style.config) + TitlebarControlsGapDragView(config: config, buttonCount: sidebarChromeButtonCount) .frame( width: MinimalModeSidebarTitlebarControlsMetrics.hostWidth, height: MinimalModeSidebarTitlebarControlsMetrics.hostHeight ) MinimalModeSidebarControlActionProxyView( - config: style.config, + config: config, + buttonCount: sidebarChromeButtonCount, requiresRevealedState: true ) { slot, anchorView, _ in switch slot { @@ -1433,7 +1527,7 @@ struct HiddenTitlebarSidebarControlsView: View { height: MinimalModeSidebarTitlebarControlsMetrics.hostHeight, alignment: .leading ) - .background(MinimalModeTitlebarButtonHitRegionView(config: style.config)) + .background(MinimalModeTitlebarButtonHitRegionView(config: config, buttonCount: sidebarChromeButtonCount)) .onReceive(MinimalModeSidebarChromeHoverState.shared.$hoveredWindowNumber) { hoveredWindowNumber in isHoveringWindowChrome = hostWindowNumber == hoveredWindowNumber #if DEBUG @@ -1922,7 +2016,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont onNewTab: newTab, onFocusHistoryBack: focusHistoryBack, onFocusHistoryForward: focusHistoryForward, - visibilityMode: .alwaysVisible + visibilityMode: .alwaysVisible, + actionSlots: TitlebarShortcutHintActionSlot.sidebarChromeSlots ) ) @@ -2035,7 +2130,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont guard showsWorkspaceTitlebar else { return } let styleRawValue = UserDefaults.standard.integer(forKey: "titlebarControlsStyle") let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic - let contentSize = TitlebarControlsLayoutMetrics.contentSize(config: style.config) + let contentSize = TitlebarControlsLayoutMetrics.contentSize( + config: style.config, + buttonCount: TitlebarShortcutHintActionSlot.sidebarChromeSlots.count, + reservesShortcutHintOverflow: true + ) if intrinsicSizeNeedsRefresh { hostingView.invalidateIntrinsicContentSize() intrinsicSizeNeedsRefresh = false diff --git a/Sources/WindowChromeMetrics.swift b/Sources/WindowChromeMetrics.swift index e2fd085901..a71bf8c0f4 100644 --- a/Sources/WindowChromeMetrics.swift +++ b/Sources/WindowChromeMetrics.swift @@ -19,10 +19,11 @@ enum MinimalModeChromeMetrics { } enum HeaderChromeControlMetrics { - static let buttonSize: CGFloat = 20 - static let iconSize: CGFloat = 12 - static let iconFrameSize: CGFloat = 14 - static let cornerRadius: CGFloat = 6 + // Release-era (v0.64.10) titlebar chrome sizing: 15pt icons in 24pt button lanes. + static let buttonSize: CGFloat = 24 + static let iconSize: CGFloat = 15 + static let iconFrameSize: CGFloat = 17 + static let cornerRadius: CGFloat = 8 static let titlebarControlsLeadingPadding: CGFloat = 4 static func iconFrameSize(forIconSize iconSize: CGFloat) -> CGFloat { @@ -34,13 +35,13 @@ enum RightSidebarChromeMetrics { static let titlebarHeight: CGFloat = WindowChromeMetrics.appTitlebarHeight static let secondaryBarHeight: CGFloat = WindowChromeMetrics.secondaryTitlebarHeight static let barHorizontalPadding: CGFloat = 8 - static let barVerticalPadding: CGFloat = 4 + static let barVerticalPadding: CGFloat = (secondaryBarHeight - HeaderChromeControlMetrics.buttonSize) / 2 static let controlHeight: CGFloat = secondaryBarHeight - (barVerticalPadding * 2) static let controlHorizontalPadding: CGFloat = 8 static let controlCornerRadius: CGFloat = 5 static let headerControlSize: CGFloat = HeaderChromeControlMetrics.buttonSize - static let headerIconSize: CGFloat = 10 - static let headerIconFrameSize: CGFloat = headerIconSize + static let headerIconSize: CGFloat = HeaderChromeControlMetrics.iconSize + static let headerIconFrameSize: CGFloat = HeaderChromeControlMetrics.iconFrameSize(forIconSize: headerIconSize) static let headerControlSpacing: CGFloat = 4 static let headerControlCornerRadius: CGFloat = HeaderChromeControlMetrics.cornerRadius static let headerControlCenterAlignmentAdjustment: CGFloat = 0 diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 2a8782cd24..eceb6aae42 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -671,7 +671,7 @@ enum MinimalModeSidebarTitlebarControlsMetrics { MinimalModeTitlebarDebugSettings.leftControlsTopInset(defaults: defaults) } - static let hostWidth: CGFloat = 164 + static let hostWidth: CGFloat = 124 static let hostHeight: CGFloat = 28 static let singleButtonHostWidth: CGFloat = hostHeight @@ -924,7 +924,7 @@ func isMinimalModeSidebarChromeHoverCandidate( private func titlebarControlsStyleConfig(defaults: UserDefaults) -> TitlebarControlsStyleConfig { let style = TitlebarControlsStyle(rawValue: defaults.integer(forKey: "titlebarControlsStyle")) ?? .classic - return style.config + return titlebarControlsSidebarChromeConfig(for: style) } func minimalModeSidebarControlActionSlot( diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 5668bd9799..acca02734b 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -2718,27 +2718,27 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(WindowChromeMetrics.clampedTitlebarHeight(96), 72) } - func testRightSidebarHeaderChromeUsesSharedButtonsWithCompactIcons() { + func testRightSidebarHeaderChromeUsesSharedButtonsAndIcons() { let titlebarConfig = TitlebarControlsStyle.classic.config + XCTAssertEqual(HeaderChromeControlMetrics.buttonSize, 24, accuracy: 0.001) + XCTAssertEqual(HeaderChromeControlMetrics.iconSize, 15, accuracy: 0.001) + XCTAssertEqual(HeaderChromeControlMetrics.iconFrameSize, 17, accuracy: 0.001) + XCTAssertEqual(HeaderChromeControlMetrics.cornerRadius, 8, accuracy: 0.001) + XCTAssertEqual(HeaderChromeIconStyle.weight, .semibold) XCTAssertEqual(HeaderChromeControlMetrics.buttonSize, titlebarConfig.buttonSize, accuracy: 0.001) XCTAssertEqual(HeaderChromeControlMetrics.iconSize, titlebarConfig.iconSize, accuracy: 0.001) XCTAssertEqual(HeaderChromeControlMetrics.cornerRadius, titlebarConfig.buttonCornerRadius, accuracy: 0.001) XCTAssertEqual(RightSidebarChromeMetrics.headerControlSize, titlebarConfig.buttonSize, accuracy: 0.001) - XCTAssertEqual(RightSidebarChromeMetrics.headerIconSize, 10, accuracy: 0.001) + XCTAssertEqual(RightSidebarChromeMetrics.headerIconSize, titlebarConfig.iconSize, accuracy: 0.001) XCTAssertEqual( RightSidebarChromeMetrics.headerIconFrameSize, - RightSidebarChromeMetrics.headerIconSize, + HeaderChromeIconStyle.iconFrameSize(forIconSize: titlebarConfig.iconSize), accuracy: 0.001 ) - XCTAssertLessThan(RightSidebarChromeMetrics.headerIconSize, titlebarConfig.iconSize) - XCTAssertLessThan( - RightSidebarChromeMetrics.headerIconFrameSize, - HeaderChromeIconStyle.iconFrameSize(forIconSize: titlebarConfig.iconSize) - ) XCTAssertEqual(RightSidebarChromeMetrics.headerControlCornerRadius, titlebarConfig.buttonCornerRadius, accuracy: 0.001) XCTAssertEqual(RightSidebarChromeMetrics.controlHeight, RightSidebarChromeMetrics.headerControlSize, accuracy: 0.001) - XCTAssertEqual(RightSidebarChromeMetrics.barVerticalPadding, 4, accuracy: 0.001) + XCTAssertEqual(RightSidebarChromeMetrics.barVerticalPadding, 2, accuracy: 0.001) XCTAssertEqual(RightSidebarChromeMetrics.headerControlCenterAlignmentAdjustment, 0, accuracy: 0.001) } diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index d4fb400f7e..dc18a1b19f 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -219,12 +219,96 @@ final class TitlebarControlsSizingPolicyTests: XCTestCase { func testTitlebarControlsUseDeterministicContentSize() { let classic = TitlebarControlsLayoutMetrics.contentSize(config: TitlebarControlsStyle.classic.config) - XCTAssertEqual(classic.width, 136, accuracy: 0.001) + XCTAssertEqual(classic.width, 164, accuracy: 0.001) XCTAssertEqual(classic.height, WindowChromeMetrics.appTitlebarHeight, accuracy: 0.001) + XCTAssertEqual( + MinimalModeSidebarTitlebarControlsMetrics.hostWidth, + 124, + accuracy: 0.001, + "Sidebar titlebar chrome should use the v0.64.10 three-button host width." + ) + XCTAssertGreaterThan( + classic.width, + MinimalModeSidebarTitlebarControlsMetrics.hostWidth, + "The full titlebar accessory may include focus-history buttons, but the sidebar host should not." + ) + let sidebarClassic = TitlebarControlsLayoutMetrics.contentSize( + config: TitlebarControlsStyle.classic.config, + buttonCount: TitlebarShortcutHintActionSlot.sidebarChromeSlots.count + ) + XCTAssertEqual(sidebarClassic.width, 96, accuracy: 0.001) + XCTAssertLessThanOrEqual(sidebarClassic.width, MinimalModeSidebarTitlebarControlsMetrics.hostWidth) + let sidebarClassicWithShortcutHints = TitlebarControlsLayoutMetrics.contentSize( + config: TitlebarControlsStyle.classic.config, + buttonCount: TitlebarShortcutHintActionSlot.sidebarChromeSlots.count, + reservesShortcutHintOverflow: true + ) + XCTAssertEqual(sidebarClassicWithShortcutHints.width, 104, accuracy: 0.001) + XCTAssertLessThanOrEqual( + sidebarClassicWithShortcutHints.width, + MinimalModeSidebarTitlebarControlsMetrics.hostWidth + ) + for style in TitlebarControlsStyle.allCases { + let sidebarConfig = titlebarControlsSidebarChromeConfig(for: style) + let sidebarContentSize = TitlebarControlsLayoutMetrics.contentSize( + config: sidebarConfig, + buttonCount: TitlebarShortcutHintActionSlot.sidebarChromeSlots.count, + reservesShortcutHintOverflow: true + ) + XCTAssertLessThanOrEqual( + sidebarContentSize.width, + MinimalModeSidebarTitlebarControlsMetrics.hostWidth, + "\(style.menuTitle) sidebar chrome should fit inside the fixed sidebar host width." + ) + } + let roomySidebarConfig = titlebarControlsSidebarChromeConfig(for: .roomy) + XCTAssertEqual(roomySidebarConfig.iconSize, 16, accuracy: 0.001) + XCTAssertEqual(roomySidebarConfig.buttonSize, 28, accuracy: 0.001) + XCTAssertEqual(roomySidebarConfig.spacing, 14, accuracy: 0.001) let compact = TitlebarControlsLayoutMetrics.contentSize(config: TitlebarControlsStyle.compact.config) - XCTAssertEqual(compact.width, 126, accuracy: 0.001) + XCTAssertEqual(compact.width, 128, accuracy: 0.001) XCTAssertEqual(compact.height, WindowChromeMetrics.appTitlebarHeight, accuracy: 0.001) + + let classicWithShortcutHints = TitlebarControlsLayoutMetrics.contentSize( + config: TitlebarControlsStyle.classic.config, + reservesShortcutHintOverflow: true + ) + XCTAssertEqual(classicWithShortcutHints.width, 172, accuracy: 0.001) + } + + func testTitlebarControlStylesKeepReleaseIconMetrics() { + let expectedMetrics: [(style: TitlebarControlsStyle, iconSize: CGFloat, buttonSize: CGFloat, cornerRadius: CGFloat)] = [ + (.classic, 15, 24, 8), + (.compact, 13, 20, 6), + (.roomy, 16, 28, 10), + (.pillGroup, 14, 24, 8), + (.softButtons, 15, 26, 8), + ] + + for expected in expectedMetrics { + let config = expected.style.config + + XCTAssertEqual(config.iconSize, expected.iconSize, accuracy: 0.001) + XCTAssertEqual(config.buttonSize, expected.buttonSize, accuracy: 0.001) + XCTAssertEqual(config.buttonCornerRadius, expected.cornerRadius, accuracy: 0.001) + } + } + + func testTitlebarControlStylesUseLayoutSpacingForFiveButtons() { + let expectedSpacing: [(style: TitlebarControlsStyle, spacing: CGFloat)] = [ + (.classic, 10), + (.compact, 6), + (.roomy, 14), + (.pillGroup, 8), + (.softButtons, 8), + ] + + for expected in expectedSpacing { + let config = expected.style.config + + XCTAssertEqual(config.spacing, expected.spacing, accuracy: 0.001) + } } func testTitlebarControlsLeadingOffsetDoesNotDoubleApplyTrafficLightPosition() { @@ -346,13 +430,13 @@ final class TitlebarControlsHoverPolicyTests: XCTestCase { let smallest = sizes.min() ?? 0 let largest = sizes.max() ?? 0 - XCTAssertLessThanOrEqual(largest - smallest, 4) + XCTAssertLessThanOrEqual(largest - smallest, 8) for style in TitlebarControlsStyle.allCases { let config = style.config let ranges = TitlebarControlsHitRegions.buttonXRanges(config: config) - XCTAssertEqual(ranges.count, MinimalModeSidebarControlActionSlot.allCases.count) + XCTAssertEqual(ranges.count, 3) for range in ranges { XCTAssertEqual( range.upperBound - range.lowerBound, diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 626b646580..67963a8a0c 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -867,7 +867,8 @@ final class WindowDragHandleHitTests: XCTestCase { func testTitlebarControlGapsAreOutsideButtonHitColumns() { let config = TitlebarControlsStyle.classic.config let ranges = TitlebarControlsHitRegions.buttonXRanges(config: config) - XCTAssertEqual(ranges.count, MinimalModeSidebarControlActionSlot.allCases.count) + XCTAssertEqual(ranges.count, TitlebarControlsHitRegions.sidebarChromeButtonCount) + XCTAssertEqual(ranges.count, 3) XCTAssertEqual( ranges[0].lowerBound, TitlebarControlsLayoutMetrics.hintLeadingPadding + config.groupPadding.leading, @@ -926,14 +927,14 @@ final class WindowDragHandleHitTests: XCTestCase { defer { MinimalModeTitlebarControlHitRegionRegistry.unregister(controlRegion) } let ranges = TitlebarControlsHitRegions.buttonXRanges(config: controlRegion.config) - let backButtonPoint = NSPoint( - x: controlRegion.frame.minX + ranges[MinimalModeSidebarControlActionSlot.focusHistoryBack.rawValue].lowerBound + 1, + let newWorkspaceButtonPoint = NSPoint( + x: controlRegion.frame.minX + ranges[MinimalModeSidebarControlActionSlot.newTab.rawValue].lowerBound + 1, y: controlRegion.frame.midY ) - XCTAssertTrue(isMinimalModeTitlebarControlHit(window: window, locationInWindow: backButtonPoint)) + XCTAssertTrue(isMinimalModeTitlebarControlHit(window: window, locationInWindow: newWorkspaceButtonPoint)) XCTAssertFalse( windowDragHandleShouldCaptureHit( - dragHandle.convert(backButtonPoint, from: nil), + dragHandle.convert(newWorkspaceButtonPoint, from: nil), in: dragHandle, eventType: .leftMouseDown, eventWindow: window