diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index 58df8d3..9f98420 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -6,7 +6,8 @@ struct Agents: View { @State private var viewAll = false @State private var expandedItem: VPNMenuItem.ID? @State private var hasToggledExpansion: Bool = false - private let defaultVisibleRows = 5 + @State private var scrollState = ScrollAffordanceState() + private let defaultVisibleRows = Theme.defaultVisibleAgents let inspection = Inspection() @@ -17,15 +18,29 @@ struct Agents: View { let items = vpn.menuState.sorted let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows) ScrollView(showsIndicators: false) { - ForEach(visibleItems, id: \.id) { agent in - MenuItemView( - item: agent, - baseAccessURL: state.baseAccessURL!, - expandedItem: $expandedItem, - userInteracted: $hasToggledExpansion - ) - .padding(.horizontal, Theme.Size.trayMargin) - }.onChange(of: visibleItems) { + VStack(spacing: 3) { + ForEach(visibleItems, id: \.id) { agent in + MenuItemView( + item: agent, + baseAccessURL: state.baseAccessURL!, + expandedItem: $expandedItem, + userInteracted: $hasToggledExpansion + ) + .padding(.horizontal, Theme.Size.trayMargin) + } + } + .background { + GeometryReader { contentProxy in + Color.clear.preference( + key: ScrollMetricsPreferenceKey.self, + value: ScrollMetrics( + contentMinY: contentProxy.frame(in: .named("agentsScroll")).minY, + contentHeight: contentProxy.size.height + ) + ) + } + } + .onChange(of: visibleItems) { // If no workspaces are online, we should expand the first one to come online if visibleItems.filter({ $0.status != .off }).isEmpty { hasToggledExpansion = false @@ -40,7 +55,31 @@ struct Agents: View { hasToggledExpansion = true } } + .background { + GeometryReader { containerProxy in + Color.clear.preference( + key: ScrollMetricsPreferenceKey.self, + value: ScrollMetrics(containerHeight: containerProxy.size.height) + ) + } + } + .coordinateSpace(name: "agentsScroll") + .mask { + if viewAll { + ScrollAffordanceMask(state: scrollState) + } else { + Color.white + } + } + .overlay { + if viewAll, scrollState.showsTopAffordance || scrollState.showsBottomAffordance { + ScrollAffordanceChevronOverlay(state: scrollState) + } + } .scrollBounceBehavior(.basedOnSize) + .onPreferenceChange(ScrollMetricsPreferenceKey.self) { metrics in + scrollState = ScrollAffordanceState(metrics: metrics) + } .frame(maxHeight: 400) if items.count == 0 { Text("No workspaces!") @@ -49,17 +88,127 @@ struct Agents: View { .padding(.horizontal, Theme.Size.trayInset) .padding(.top, 2) } - // Only show the toggle if there are more items to show if items.count > defaultVisibleRows { - Toggle(isOn: $viewAll) { - Text(viewAll ? "Show less" : "Show all") - .font(.headline) - .foregroundColor(.secondary) - .padding(.horizontal, Theme.Size.trayInset) - .padding(.top, 2) - }.toggleStyle(.button).buttonStyle(.plain) + Button { + viewAll.toggle() + } label: { + ButtonRowView { + HStack(spacing: Theme.Size.trayPadding) { + Text(viewAll ? "Show less" : "Show all") + .font(.subheadline) + .fontWeight(.bold) + Spacer() + Image(systemName: viewAll ? "chevron.up" : "chevron.right") + .font(.system(size: 10, weight: .medium)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, Theme.Size.trayMargin) + .buttonStyle(.plain) } } }.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector } } + +private struct ScrollAffordanceState: Equatable { + var showsTopAffordance = false + var showsBottomAffordance = false + + init() {} + + init(metrics: ScrollMetrics) { + let contentHeight = metrics.contentHeight + let containerHeight = metrics.containerHeight + let offsetY = max(-metrics.contentMinY, 0) + let canScroll = contentHeight > containerHeight + 1 + + guard canScroll else { return } + + showsTopAffordance = offsetY > 1 + showsBottomAffordance = offsetY + containerHeight < contentHeight - 1 + } +} + +private struct ScrollMetrics: Equatable { + var contentMinY: CGFloat = 0 + var contentHeight: CGFloat = 0 + var containerHeight: CGFloat = 0 +} + +private struct ScrollMetricsPreferenceKey: PreferenceKey { + static let defaultValue = ScrollMetrics() + + static func reduce(value: inout ScrollMetrics, nextValue: () -> ScrollMetrics) { + let next = nextValue() + if next.contentHeight != 0 { + value.contentMinY = next.contentMinY + value.contentHeight = next.contentHeight + } + if next.containerHeight != 0 { + value.containerHeight = next.containerHeight + } + } +} + +private struct ScrollAffordanceMask: View { + let state: ScrollAffordanceState + + var body: some View { + VStack(spacing: 0) { + ScrollAffordanceMaskEdge(direction: .top, isVisible: state.showsTopAffordance) + Color.white + ScrollAffordanceMaskEdge(direction: .bottom, isVisible: state.showsBottomAffordance) + } + .allowsHitTesting(false) + } +} + +private struct ScrollAffordanceChevronOverlay: View { + let state: ScrollAffordanceState + + var body: some View { + VStack(spacing: 0) { + if state.showsTopAffordance { + Image(systemName: "chevron.up") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + Spacer(minLength: 0) + if state.showsBottomAffordance { + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.bottom, 2) + } + } + .allowsHitTesting(false) + } +} + +private struct ScrollAffordanceMaskEdge: View { + enum Direction { + case top + case bottom + } + + let direction: Direction + let isVisible: Bool + + var body: some View { + Group { + if isVisible { + LinearGradient( + colors: direction == .top ? [.clear, .white] : [.white, .clear], + startPoint: .top, + endPoint: .bottom + ) + } else { + Color.white + } + } + .frame(height: 16) + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift index a48be35..bf07ba1 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift @@ -43,7 +43,8 @@ struct VPNMenu: View { } Divider() Text("Workspaces") - .font(.headline) + .font(.subheadline) + .fontWeight(.bold) .foregroundColor(.secondary) VPNState() }.padding([.horizontal, .top], Theme.Size.trayInset) diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift index 8f84ab3..ce5a2a1 100644 --- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift @@ -67,28 +67,27 @@ struct AgentsTests { } @Test - func showAllToggle() async throws { + func showAllButton() async throws { vpn.state = .connected vpn.menuState = .init(agents: createMockAgents(count: 7)) try await ViewHosting.host(view) { try await sut.inspection.inspect { view in - var toggle = try view.find(ViewType.Toggle.self) + var button = try view.find(ViewType.Button.self) var forEach = try view.find(ViewType.ForEach.self) #expect(forEach.count == Theme.defaultVisibleAgents) - #expect(try toggle.labelView().text().string() == "Show all") - #expect(try !toggle.isOn()) + #expect(try button.labelView().find(text: "Show all").string() == "Show all") - try toggle.tap() - toggle = try view.find(ViewType.Toggle.self) + try button.tap() + button = try view.find(ViewType.Button.self) forEach = try view.find(ViewType.ForEach.self) #expect(forEach.count == Theme.defaultVisibleAgents + 2) - #expect(try toggle.labelView().text().string() == "Show less") + #expect(try button.labelView().find(text: "Show less").string() == "Show less") - try toggle.tap() - toggle = try view.find(ViewType.Toggle.self) + try button.tap() + button = try view.find(ViewType.Button.self) forEach = try view.find(ViewType.ForEach.self) - #expect(try toggle.labelView().text().string() == "Show all") + #expect(try button.labelView().find(text: "Show all").string() == "Show all") #expect(forEach.count == Theme.defaultVisibleAgents) } } @@ -100,7 +99,7 @@ struct AgentsTests { vpn.menuState = .init(agents: createMockAgents(count: 3)) #expect(throws: (any Error).self) { - _ = try view.inspect().find(ViewType.Toggle.self) + _ = try view.inspect().find(ViewType.Button.self) } }