diff --git a/AgentBar/Views/StatusBar/StackedBarView.swift b/AgentBar/Views/StatusBar/StackedBarView.swift index 3a3277f..96ba9f0 100644 --- a/AgentBar/Views/StatusBar/StackedBarView.swift +++ b/AgentBar/Views/StatusBar/StackedBarView.swift @@ -11,6 +11,18 @@ struct StackedBarView: View { StatusBarDisplayPlanner.rankedServices(from: services) } + private var rowHeight: CGFloat { + StatusBarDisplayPlanner.rowHeight(forServiceCount: rankedServices.count) + } + + private var rowSpacing: CGFloat { + StatusBarDisplayPlanner.rowSpacing(forServiceCount: rankedServices.count) + } + + private var shouldCenterRowsVertically: Bool { + StatusBarDisplayPlanner.centersRowsVertically(forServiceCount: rankedServices.count) + } + private var cycleTaskID: String { let signature = rankedServices .map { usage in @@ -52,19 +64,22 @@ struct StackedBarView: View { } private var scrollingRows: some View { - ZStack(alignment: .top) { - VStack(spacing: StatusBarDisplayPlanner.rowSpacing) { + ZStack(alignment: shouldCenterRowsVertically ? .center : .top) { + VStack(spacing: rowSpacing) { ForEach(rankedServices) { usage in SingleBarView(usage: usage) - .frame(height: StatusBarDisplayPlanner.rowHeight) + .frame(height: rowHeight) } } .offset( y: -CGFloat(currentScrollIndex) - * (StatusBarDisplayPlanner.rowHeight + StatusBarDisplayPlanner.rowSpacing) + * (rowHeight + rowSpacing) ) } - .frame(height: StatusBarDisplayPlanner.viewportHeight, alignment: .top) + .frame( + height: StatusBarDisplayPlanner.viewportHeight, + alignment: shouldCenterRowsVertically ? .center : .top + ) .clipped() } @@ -143,6 +158,5 @@ struct SingleBarView: View { } } } - .frame(height: StatusBarDisplayPlanner.rowHeight) } } diff --git a/AgentBar/Views/StatusBar/StatusBarDisplayPlanner.swift b/AgentBar/Views/StatusBar/StatusBarDisplayPlanner.swift index d206993..cf46a8d 100644 --- a/AgentBar/Views/StatusBar/StatusBarDisplayPlanner.swift +++ b/AgentBar/Views/StatusBar/StatusBarDisplayPlanner.swift @@ -2,9 +2,9 @@ import Foundation import CoreGraphics enum StatusBarDisplayPlanner { - static let visibleRowCount = 3 - static let rowHeight: CGFloat = 6 - static let rowSpacing: CGFloat = 1 + static let maximumVisibleRowCount = 3 + static let compactVisibleRowCount = 2 + static let standardRowSpacing: CGFloat = 1 static let viewportHeight: CGFloat = 20 static let topPriorityHoldSeconds: TimeInterval = 8 @@ -30,11 +30,52 @@ enum StatusBarDisplayPlanner { } static func maxScrollIndex(for rankedServices: [UsageData]) -> Int { - max(0, rankedServices.count - visibleRowCount) + max(0, rankedServices.count - visibleRowCount(forServiceCount: rankedServices.count)) + } + + static func visibleRowCount(forServiceCount serviceCount: Int) -> Int { + guard serviceCount > 0 else { return maximumVisibleRowCount } + return serviceCount <= compactVisibleRowCount + ? compactVisibleRowCount + : maximumVisibleRowCount + } + + static func rowHeight(forServiceCount serviceCount: Int) -> CGFloat { + if usesCompactLayout(forServiceCount: serviceCount) { + return compactRowHeight + } + + let rowCount = CGFloat(visibleRowCount(forServiceCount: serviceCount)) + let totalSpacing = standardRowSpacing * max(0, rowCount - 1) + return (viewportHeight - totalSpacing) / rowCount + } + + static func rowSpacing(forServiceCount serviceCount: Int) -> CGFloat { + switch serviceCount { + case 2: + return (viewportHeight - (rowHeight(forServiceCount: serviceCount) * 2)) / 3 + case 1: + return 0 + default: + return standardRowSpacing + } + } + + static func centersRowsVertically(forServiceCount serviceCount: Int) -> Bool { + usesCompactLayout(forServiceCount: serviceCount) } private static func usageScore(_ data: UsageData) -> Double { let weekly = data.weeklyUsage?.percentage ?? 0 return max(data.fiveHourUsage.percentage, weekly) } + + private static var compactRowHeight: CGFloat { + let fullCompactRowHeight = (viewportHeight - standardRowSpacing) / CGFloat(compactVisibleRowCount) + return fullCompactRowHeight * 0.8 + } + + private static func usesCompactLayout(forServiceCount serviceCount: Int) -> Bool { + serviceCount > 0 && serviceCount <= compactVisibleRowCount + } } diff --git a/AgentBarTests/StatusBarDisplayPlannerTests.swift b/AgentBarTests/StatusBarDisplayPlannerTests.swift index 13f0f36..8b912a8 100644 --- a/AgentBarTests/StatusBarDisplayPlannerTests.swift +++ b/AgentBarTests/StatusBarDisplayPlannerTests.swift @@ -59,6 +59,44 @@ final class StatusBarDisplayPlannerTests: XCTestCase { XCTAssertEqual(StatusBarDisplayPlanner.maxScrollIndex(for: ranked), 0) } + func testUsesTwoVisibleRowsWhenOneOrTwoServicesAreActive() { + XCTAssertEqual(StatusBarDisplayPlanner.visibleRowCount(forServiceCount: 1), 2) + XCTAssertEqual(StatusBarDisplayPlanner.visibleRowCount(forServiceCount: 2), 2) + XCTAssertEqual(StatusBarDisplayPlanner.visibleRowCount(forServiceCount: 3), 3) + } + + func testCompactLayoutUsesHalfHeightRows() { + XCTAssertEqual( + StatusBarDisplayPlanner.rowHeight(forServiceCount: 2), + 7.6, + accuracy: 0.001 + ) + XCTAssertEqual( + StatusBarDisplayPlanner.rowHeight(forServiceCount: 3), + 6, + accuracy: 0.001 + ) + } + + func testCompactLayoutUsesEvenVerticalSpacing() { + XCTAssertEqual( + StatusBarDisplayPlanner.rowSpacing(forServiceCount: 2), + 1.6, + accuracy: 0.001 + ) + XCTAssertEqual( + StatusBarDisplayPlanner.rowSpacing(forServiceCount: 3), + 1, + accuracy: 0.001 + ) + } + + func testCompactLayoutCentersRowsVertically() { + XCTAssertTrue(StatusBarDisplayPlanner.centersRowsVertically(forServiceCount: 1)) + XCTAssertTrue(StatusBarDisplayPlanner.centersRowsVertically(forServiceCount: 2)) + XCTAssertFalse(StatusBarDisplayPlanner.centersRowsVertically(forServiceCount: 3)) + } + func testMaxScrollIndexEqualsOverflowRowCount() { let services = [ makeUsage(service: .claude, fiveHourPct: 0.99),