From e8b85c45da8de1b5c56992df4ea74a0948fa6420 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 18 Sep 2026 20:15:17 +0800 Subject: [PATCH] Add compact Metrics detail switching --- Memory/ARCHITECTURE.md | 11 +- TFM/App/RootView.swift | 4 + .../Metrics/MetricsConnectionNotice.swift | 39 ++++- .../Metrics/MetricsDetailLayout.swift | 48 ++++++ TFM/Features/Metrics/MetricsView.swift | 42 ++--- TFM/Features/Workspaces/WindowModel.swift | 35 +++- TFM/UI/Components/WindowToolbar.swift | 47 +++++- TFMTests/MetricsDetailToolbarTests.swift | 150 ++++++++++++++++++ TFMTests/MetricsInteractionTests.swift | 68 ++++++++ TFMTests/ResponsivePageLayoutTests.swift | 41 ++++- 10 files changed, 447 insertions(+), 38 deletions(-) create mode 100644 TFM/Features/Metrics/MetricsDetailLayout.swift create mode 100644 TFMTests/MetricsDetailToolbarTests.swift diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index fa84c19b..70da94de 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -108,8 +108,15 @@ cleanup is performed. - Desktop main pages share a 360-point detail minimum, independent of the Assistant's 840-point reading maximum. Opening the Navigator grows the window when necessary. Vault overlays its existing editor below 721 points and keeps - the covered list mounted at its last visible size; Metrics stacks its existing - detail and process views below 630 points. Resizing changes layout, never route, + the covered list mounted at its last visible size. Below 700 points Metrics uses + a titlebar Status/Processes picker with the toolbar title hidden; at larger + widths both panels and the title are visible. + Covered Metrics panels retain their last visible size, table identity, sorting, + and scroll position. Page selection and measured detail width are window-local; + selecting another server resets to Status. Focusing search or narrowing while + search is focused reveals Processes. Compact actions group Pause/Resume into + More, and macOS 15 presents its search field in a toolbar popover. + Resizing changes layout, never route, editor identity, Workspace composition, or monitoring ownership. - Assistant conversations are owned by `AssistantLibraryModel`; they are not Workspace panes. A Workspace owns SSH/SFTP/serial panes and any session-scoped diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 0f4c4ee7..1f31450d 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -93,6 +93,7 @@ struct RootView: View { WindowTitleCatalog.standard(hosts: app.hostLibrary) .title(in: window, locale: locale) ) + .toolbar(removing: window.showsCompactMetricsDetail ? .title : nil) // The window's only toolbar. Creation on the left, one Find on the // right, both following the content in front rather than the sidebar — // see `WindowToolbarLeadingActions` and `WindowModel.findKind`. @@ -100,6 +101,9 @@ struct RootView: View { ToolbarItemGroup(placement: .navigation) { WindowToolbarLeadingActions() } + ToolbarItem(placement: .principal) { + MetricsDetailPagePicker() + } ToolbarItem(placement: .primaryAction) { WindowToolbarSearchSlot() } diff --git a/TFM/Features/Metrics/MetricsConnectionNotice.swift b/TFM/Features/Metrics/MetricsConnectionNotice.swift index 2fd63cf1..5cea3be7 100644 --- a/TFM/Features/Metrics/MetricsConnectionNotice.swift +++ b/TFM/Features/Metrics/MetricsConnectionNotice.swift @@ -16,6 +16,24 @@ struct MetricsConnectionNotice: View { } } +struct MetricsDetailPagePicker: View { + @Environment(WindowModel.self) private var window + + var body: some View { + @Bindable var window = window + if window.showsCompactMetricsDetail { + Picker("Details", selection: $window.metricsDetailPage) { + Image(systemName: "gauge.with.dots.needle.33percent") + .accessibilityLabel("Status").help("Status").tag(MetricsDetailPage.status) + Image(systemName: "list.bullet.rectangle") + .accessibilityLabel("Processes").help("Processes").tag(MetricsDetailPage.processes) + } + .pickerStyle(.segmented) + .frame(width: 104) + } + } +} + struct MetricsDetailToolbar: View { let server: ServerMetric let model: ServerMetricsModel @@ -23,16 +41,12 @@ struct MetricsDetailToolbar: View { @State private var configuring = false var body: some View { - Button { - model.setEnabled(!server.selection.enabled, for: server) - } label: { - Label( - server.selection.enabled ? "Pause" : "Resume", systemImage: server.selection.enabled ? "pause" : "play" - ) - .labelStyle(.iconOnly) + if !window.showsCompactMetricsDetail { + pauseButton.labelStyle(.iconOnly) + .help(server.selection.enabled ? Text("Pause") : Text("Resume")) } - .help(server.selection.enabled ? Text("Pause") : Text("Resume")) Menu { + if window.showsCompactMetricsDetail { pauseButton } Button("Retry", systemImage: MenuSymbol.refresh) { model.retry(server) } Button("Select Devices", systemImage: MenuSymbol.selectDevices) { configuring = true } Divider() @@ -45,4 +59,13 @@ struct MetricsDetailToolbar: View { } .sheet(isPresented: $configuring) { MetricsDevicePicker(server: server, model: model) } } + + private var pauseButton: some View { + Button { + model.setEnabled(!server.selection.enabled, for: server) + } label: { + Label( + server.selection.enabled ? "Pause" : "Resume", systemImage: server.selection.enabled ? "pause" : "play") + } + } } diff --git a/TFM/Features/Metrics/MetricsDetailLayout.swift b/TFM/Features/Metrics/MetricsDetailLayout.swift new file mode 100644 index 00000000..6328c9ef --- /dev/null +++ b/TFM/Features/Metrics/MetricsDetailLayout.swift @@ -0,0 +1,48 @@ +import SwiftUI + +nonisolated enum MetricsDetailPage: CaseIterable { + case status, processes + + static func showsBothColumns(width: CGFloat) -> Bool { width >= 700 } +} + +/// Both native scroll views stay mounted. A covered panel keeps its last visible +/// size so switching pages or resizing cannot reset its table or scroll position. +struct MetricsDetailLayout: Layout { + var split: Bool + var page: MetricsDetailPage + + struct Cache { + var statusSize: CGSize? + var processSize: CGSize? + } + + func makeCache(subviews: Subviews) -> Cache { Cache() } + + func updateCache(_ cache: inout Cache, subviews: Subviews) {} + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { + guard subviews.count == 2 else { return } + let statusWidth = split ? min(340, max(270, (bounds.width + 32) * 0.38)) : bounds.width + let processWidth = split ? max(0, bounds.width - statusWidth - 16) : bounds.width + let showsStatus = split || page == .status + let showsProcesses = split || page == .processes + let statusSize = + showsStatus + ? CGSize(width: statusWidth, height: bounds.height) : cache.statusSize ?? bounds.size + let processSize = + showsProcesses + ? CGSize(width: processWidth, height: bounds.height) : cache.processSize ?? bounds.size + if showsStatus { cache.statusSize = statusSize } + if showsProcesses { cache.processSize = processSize } + + subviews[0].place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(statusSize)) + subviews[1].place( + at: CGPoint(x: split ? bounds.minX + statusWidth + 16 : bounds.minX, y: bounds.minY), + anchor: .topLeading, proposal: ProposedViewSize(processSize)) + } +} diff --git a/TFM/Features/Metrics/MetricsView.swift b/TFM/Features/Metrics/MetricsView.swift index 9ff62770..10ce288d 100644 --- a/TFM/Features/Metrics/MetricsView.swift +++ b/TFM/Features/Metrics/MetricsView.swift @@ -62,32 +62,38 @@ struct ServerMetricsView: View { struct MetricsDetailView: View { let server: ServerMetric let model: ServerMetricsModel - - static let splitMinWidth: CGFloat = 630 - static let compactProcessHeight: CGFloat = 360 + @Environment(WindowModel.self) private var window var body: some View { GeometryReader { geometry in - let compact = geometry.size.width < Self.splitMinWidth - let layout = - compact - ? AnyLayout(VStackLayout(alignment: .leading, spacing: 16)) - : AnyLayout(HStackLayout(alignment: .top, spacing: 16)) + let split = MetricsDetailPage.showsBothColumns(width: geometry.size.width) + let showsStatus = split || window.metricsDetailPage == .status + let showsProcesses = split || window.metricsDetailPage == .processes - ScrollView { - layout { + MetricsDetailLayout(split: split, page: window.metricsDetailPage) { + ScrollView { MetricsServerDetails(server: server, model: model) .padding(1) - .frame(width: compact ? nil : min(340, max(270, geometry.size.width * 0.38))) - .fixedSize(horizontal: false, vertical: true) - - MetricsProcessList(server: server) - .frame(height: compact ? Self.compactProcessHeight : max(0, geometry.size.height - 32)) - .frame(maxWidth: .infinity) } - .padding(16) + .metricsPlainScrollEdges() + .opacity(showsStatus ? 1 : 0) + .allowsHitTesting(showsStatus) + .disabled(!showsStatus) + .accessibilityHidden(!showsStatus) + + MetricsProcessList(server: server) + .opacity(showsProcesses ? 1 : 0) + .allowsHitTesting(showsProcesses) + .disabled(!showsProcesses) + .accessibilityHidden(!showsProcesses) } - .metricsPlainScrollEdges() + .padding(16) + .clipped() + } + .onGeometryChange(for: CGFloat.self) { + $0.size.width + } action: { + window.metricsDetailWidth = $0 } } } diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 509b277c..29d11d49 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -51,9 +51,30 @@ final class WindowModel { private var settingsReturnDestination: WindowDestination = .vault weak var metricsModel: ServerMetricsModel? var pendingMetricsHostIDs: [UUID] = [] - var metricsHostID: UUID? + var metricsHostID: UUID? { + didSet { + if metricsHostID != oldValue { metricsDetailPage = .status } + } + } + var metricsDetailPage = MetricsDetailPage.status + var metricsDetailWidth: CGFloat = 0 { + didSet { + if isToolbarFindFocused { revealMetricsProcessesForFind() } + } + } + var showsCompactMetricsDetail: Bool { + guard selectedDestination == .metrics, metricsDetailWidth > 0, + !MetricsDetailPage.showsBothColumns(width: metricsDetailWidth), + let server = metricsModel?.servers.first(where: { $0.id == metricsHostID }) + else { return false } + return !server.showsConnectionJourney + } var metricsQuery = "" - var metricsProcessQuery = "" + var metricsProcessQuery = "" { + didSet { + if isToolbarFindFocused { revealMetricsProcessesForFind() } + } + } var metricsSelection: Set = [] var settingsRoute: SettingsRoute = .index var vaultDestination: VaultDestination = .welcome @@ -351,7 +372,15 @@ final class WindowModel { /// Level, not edge. A pane has to consult it every time it is about to hold /// focus, not only when this flips: the caret is lost on *each* keystroke, /// and a stand-down that fires once cannot answer the second one. - var isToolbarFindFocused = false + var isToolbarFindFocused = false { + didSet { + if isToolbarFindFocused { revealMetricsProcessesForFind() } + } + } + + private func revealMetricsProcessesForFind() { + if showsCompactMetricsDetail { metricsDetailPage = .processes } + } /// Focuses the titlebar Find field. Installed by the field itself while it /// is mounted, and left pointing at a field that has gone away otherwise — diff --git a/TFM/UI/Components/WindowToolbar.swift b/TFM/UI/Components/WindowToolbar.swift index dae6a939..4a59013b 100644 --- a/TFM/UI/Components/WindowToolbar.swift +++ b/TFM/UI/Components/WindowToolbar.swift @@ -465,15 +465,60 @@ struct WindowToolbarSearchSlot: View { var body: some View { if #available(macOS 26.0, *) { Color.clear.frame(width: 0, height: Metrics.Toolbar.height) + } else { + WindowToolbarLegacySearch() + } + } +} + +/// Older macOS versions need an explicit compact search affordance. Keep the +/// popover inside the toolbar item so changing width never reparents page content. +struct WindowToolbarLegacySearch: View { + @Environment(WindowModel.self) private var window + + var body: some View { + if window.showsCompactMetricsDetail { + WindowToolbarCompactFind() } else { WindowToolbarFindField() } } } +private struct WindowToolbarCompactFind: View { + @Environment(WindowModel.self) private var window + @State private var isPresented = false + + var body: some View { + let presentation = $isPresented + Button("Search", systemImage: "magnifyingglass") { window.focusToolbarFind() } + .labelStyle(.iconOnly) + .help("Search") + .frame(height: Metrics.Toolbar.height) + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + WindowToolbarFindField(installFocusHandler: { focus in + window.focusToolbarFindField = { + presentation.wrappedValue = true + focus() + } + }) + .padding(12) + .onExitCommand { isPresented = false } + } + .onAppear { + window.focusToolbarFindField = { presentation.wrappedValue = true } + if window.isToolbarFindFocused { isPresented = true } + } + .onChange(of: isPresented) { _, presented in + if !presented { window.isToolbarFindFocused = false } + } + } +} + /// The trailing half of the titlebar: one Find field, or nothing at all on /// Welcome and on a workspace that has no searchable pane in front. struct WindowToolbarFindField: View { + var installFocusHandler: ((@escaping () -> Void) -> Void)? = nil @Environment(WindowModel.self) private var window @Environment(\.locale) private var locale @State private var searchFieldOwner = NativeSearchFieldOwner() @@ -489,7 +534,7 @@ struct WindowToolbarFindField: View { maintainsKeyboardFocus: window.isToolbarFindFocused, navigate: context.navigate, cancel: context.cancel, - installFocusHandler: { window.focusToolbarFindField = $0 }, + installFocusHandler: installFocusHandler ?? { window.focusToolbarFindField = $0 }, onFocusChange: { window.isToolbarFindFocused = $0 } ).singleLinePaste() .frame(width: Metrics.Toolbar.searchFieldWidth) diff --git a/TFMTests/MetricsDetailToolbarTests.swift b/TFMTests/MetricsDetailToolbarTests.swift new file mode 100644 index 00000000..a11fae15 --- /dev/null +++ b/TFMTests/MetricsDetailToolbarTests.swift @@ -0,0 +1,150 @@ +import AppKit +import SwiftData +import SwiftUI +import TermindApplication +import TermindPersistence +import Testing + +@testable import Termind + +@MainActor +@Suite(.serialized) +struct MetricsDetailToolbarTests { + @Test(arguments: [AppLanguage.english, .simplifiedChinese, .traditionalChinese]) + func compactToolbarSwitchesPagesAndSearchRevealsProcesses(language: AppLanguage) async throws { + let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: language) + let hosts = HostLibraryModel.demo(language: language) + let model = configuration.makeMetricsModel(hosts: hosts) + let app = AppModel(hosts: hosts, serverMetrics: model) + let state = configuration.makeWindow(hosts: hosts) + let root = RootView(window: state) + .environment(app) + .environment(\.locale, language.locale) + .modelContainer(try TermindPersistence.makeContainer(inMemory: true)) + let window = makeWindow(root) + defer { + window.orderOut(nil) + window.contentViewController = nil + app.shutdownForTermination() + } + await settle(window) + let frame = try #require(window.contentView?.superview) + #expect(state.showsCompactMetricsDetail) + let picker = try #require(descendants(in: frame, ofType: NSSegmentedControl.self).first) + #expect(picker.segmentCount == 2) + #expect(picker.selectedSegment == 0) + #expect(!picker.visibleRect.isEmpty) + try capture(frame, name: "\(language.rawValue)-status") + + picker.selectedSegment = 1 + picker.sendAction(picker.action, to: picker.target) + await settle(window) + #expect(state.metricsDetailPage == .processes) + try capture(frame, name: "\(language.rawValue)-processes") + picker.selectedSegment = 0 + picker.sendAction(picker.action, to: picker.target) + await settle(window) + #expect(state.metricsDetailPage == .status) + + state.focusToolbarFind() + await settle(window) + #expect(state.metricsDetailPage == .processes) + #expect(state.isToolbarFindFocused) + try capture(frame, name: "\(language.rawValue)-search") + + window.setContentSize(CGSize(width: 900, height: 600)) + await settle(window) + #expect(!state.showsCompactMetricsDetail) + #expect(descendants(in: frame, ofType: NSSegmentedControl.self).isEmpty) + window.setContentSize(CGSize(width: 360, height: 600)) + await settle(window) + #expect(state.metricsDetailPage == .processes) + #expect(state.showsCompactMetricsDetail) + let search = try #require(window.toolbar?.items.compactMap { $0 as? NSSearchToolbarItem }.first) + search.endSearchInteraction() + await settle(window) + #expect(!descendants(in: frame, ofType: NSSegmentedControl.self).isEmpty) + } + + @Test + func legacyCompactSearchOpensTheNativeFieldAndKeepsItsQueryWhenWidened() async throws { + let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english) + let model = configuration.makeMetricsModel(hosts: .demo()) + let server = try #require(model.servers.first) + let state = WindowModel() + state.selectedDestination = .metrics + state.metricsHostID = server.id + state.metricsModel = model + let root = NavigationStack { + MetricsDetailView(server: server, model: model) + .toolbar { + ToolbarItem(placement: .principal) { MetricsDetailPagePicker() } + ToolbarItem(placement: .primaryAction) { WindowToolbarLegacySearch() } + } + }.environment(state) + let window = makeWindow(root) + defer { + window.orderOut(nil) + window.contentViewController = nil + model.shutdown() + } + await settle(window) + #expect(state.showsCompactMetricsDetail) + let frame = try #require(window.contentView?.superview) + #expect(descendants(in: frame, ofType: NSSearchField.self).isEmpty) + #expect(window.toolbar != nil) + _ = try #require(state.focusToolbarFindField) + try capture(frame, name: "legacy-before-search") + state.focusToolbarFind() + await settle(window) + try capture(frame, name: "legacy-after-search") + let field = try #require( + NSApp.windows.filter(\.isVisible).compactMap(\.contentView) + .flatMap { descendants(in: $0, ofType: NSSearchField.self) }.first) + #expect(state.metricsDetailPage == .processes) + #expect(field.currentEditor() != nil) + field.stringValue = "node" + field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field)) + await settle(window) + #expect(state.metricsProcessQuery == "node") + window.setContentSize(CGSize(width: 900, height: 600)) + await settle(window) + let expandedField = try #require( + descendants(in: frame, ofType: NSSearchField.self).first) + #expect(expandedField.stringValue == "node") + #expect(state.metricsDetailPage == .processes) + } + + private func makeWindow(_ root: some View) -> NSWindow { + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 360, height: 600), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.isRestorable = false + window.contentViewController = NSHostingController(rootView: root) + window.setContentSize(CGSize(width: 360, height: 600)) + window.appearance = NSAppearance(named: .aqua) + window.center() + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + return window + } + + private func settle(_ window: NSWindow) async { + window.contentView?.layoutSubtreeIfNeeded() + try? await Task.sleep(for: .milliseconds(350)) + window.contentView?.layoutSubtreeIfNeeded() + } + + private func descendants(in view: NSView, ofType type: T.Type) -> [T] { + (view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(in: $0, ofType: type) } + } + + private func capture(_ view: NSView, name: String) throws { + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write( + to: FileManager.default.temporaryDirectory.appending(path: "termind-metrics-compact-\(name).png")) + } +} diff --git a/TFMTests/MetricsInteractionTests.swift b/TFMTests/MetricsInteractionTests.swift index 91029e2f..e9424173 100644 --- a/TFMTests/MetricsInteractionTests.swift +++ b/TFMTests/MetricsInteractionTests.swift @@ -16,6 +16,74 @@ import struct TermindDomain.Host @Suite @MainActor struct MetricsInteractionTests { + @Test + func compactMetricsPagesFollowSearchAndRemainWindowLocal() throws { + let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english) + let model = configuration.makeMetricsModel(hosts: .demo()) + defer { model.shutdown() } + let server = try #require(model.servers.first) + let window = WindowModel() + let otherWindow = WindowModel() + for state in [window, otherWindow] { + state.selectedDestination = .metrics + state.metricsModel = model + state.metricsHostID = server.id + state.metricsDetailWidth = 360 + #expect(state.showsCompactMetricsDetail) + #expect(state.metricsDetailPage == .status) + } + + window.focusToolbarFind() + #expect(window.metricsDetailPage == .processes) + #expect(otherWindow.metricsDetailPage == .status) + try #require(window.findContext()).query.wrappedValue = "node" + window.isToolbarFindFocused = false + #expect(window.metricsDetailPage == .processes) + #expect(window.metricsProcessQuery == "node") + + window.metricsDetailPage = .status + window.isToolbarFindFocused = true // Native search activation, including a mouse click. + #expect(window.metricsDetailPage == .processes) + window.metricsDetailPage = .status + window.focusToolbarFind() // Repeated Command-F while the search is already presented. + #expect(window.metricsDetailPage == .processes) + + window.metricsDetailWidth = 900 + window.metricsDetailPage = .status + window.metricsDetailWidth = 700 + #expect(!window.showsCompactMetricsDetail) + #expect(window.metricsDetailPage == .status) + window.metricsDetailWidth = 699 + #expect(window.showsCompactMetricsDetail) + #expect(window.metricsDetailPage == .processes) + + window.isToolbarFindFocused = false + window.metricsDetailPage = .status + window.metricsDetailWidth = 900 + window.metricsDetailWidth = 360 + #expect(window.metricsDetailPage == .status) + window.metricsDetailPage = .processes + window.metricsHostID = server.id + #expect(window.metricsDetailPage == .processes) + window.metricsHostID = UUID() + #expect(window.metricsDetailPage == .status) + #expect(!window.showsCompactMetricsDetail) + window.metricsHostID = nil + #expect(!window.showsCompactMetricsDetail) + window.metricsHostID = server.id + #expect(window.metricsDetailPage == .status) + + server.point = nil + server.isConnecting = true + server.journey.fail("Fixture connection failure") + #expect(server.showsConnectionJourney) + #expect(!window.showsCompactMetricsDetail) + window.focusToolbarFind() + #expect(window.metricsDetailPage == .status) + window.selectedDestination = .assistant + #expect(!window.showsCompactMetricsDetail) + } + @Test func toolbarSearchKeepsOverviewAndProcessQueriesIndependent() throws { let window = WindowModel() diff --git a/TFMTests/ResponsivePageLayoutTests.swift b/TFMTests/ResponsivePageLayoutTests.swift index 2672f64e..46ab1e78 100644 --- a/TFMTests/ResponsivePageLayoutTests.swift +++ b/TFMTests/ResponsivePageLayoutTests.swift @@ -9,11 +9,13 @@ import Testing @Suite(.serialized) struct ResponsivePageLayoutTests { @Test - func metricsResizeKeepsTheProcessTableAndMakesAllDetailsScrollable() async throws { + func metricsPagesAndResizeKeepTheProcessTableAndBothScrollPositions() async throws { let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english) let model = configuration.makeMetricsModel(hosts: .demo()) let server = try #require(model.servers.first) let state = WindowModel() + state.selectedDestination = .metrics + state.metricsModel = model state.metricsHostID = server.id let hosting = NSHostingView(rootView: MetricsDetailView(server: server, model: model).environment(state)) let window = NSWindow( @@ -28,6 +30,8 @@ struct ResponsivePageLayoutTests { await layout(hosting) let table = try #require(descendant(in: hosting, ofType: NSTableView.self)) let processScroll = try #require(table.enclosingScrollView) + let statusScroll = try #require(descendant(in: hosting, ofType: NSScrollView.self)) + #expect(statusScroll !== processScroll) let reversedSort = try #require(table.sortDescriptors.first?.reversedSortDescriptor as? NSSortDescriptor) table.sortDescriptors = [reversedSort] await layout(hosting) @@ -35,21 +39,46 @@ struct ResponsivePageLayoutTests { #expect(sort == [reversedSort]) processScroll.contentView.scroll(to: CGPoint(x: 0, y: 40)) processScroll.reflectScrolledClipView(processScroll.contentView) + let wideProcessSize = processScroll.frame.size window.setContentSize(CGSize(width: 360, height: 600)) await layout(hosting) - let outerScroll = try #require(descendant(in: hosting, ofType: NSScrollView.self)) - #expect(outerScroll !== processScroll) - #expect((outerScroll.documentView?.frame.height ?? 0) > outerScroll.contentView.bounds.height) - #expect(processScroll.frame.height > 200) + #expect(state.showsCompactMetricsDetail) + #expect(state.metricsDetailPage == .status) + #expect((statusScroll.documentView?.frame.height ?? 0) > statusScroll.contentView.bounds.height) + #expect(processScroll.frame.size == wideProcessSize) #expect(processScroll.contentView.bounds.minY > 0) #expect(descendant(in: hosting, ofType: NSTableView.self) === table) #expect(table.sortDescriptors == sort) + statusScroll.contentView.scroll(to: CGPoint(x: 0, y: 20)) + statusScroll.reflectScrolledClipView(statusScroll.contentView) + let statusSize = statusScroll.frame.size + state.metricsDetailPage = .processes + await layout(hosting) + #expect(processScroll.frame.height > 480) + #expect(processScroll.frame.width > 280) + #expect(statusScroll.frame.size == statusSize) + #expect(statusScroll.contentView.bounds.minY == 20) + #expect(processScroll.contentView.bounds.minY > 0) + + for width in [699.0, 700.0, 900.0, 360.0] { + window.setContentSize(CGSize(width: width, height: 600)) + await layout(hosting) + #expect(state.showsCompactMetricsDetail == (width < 700)) + #expect(state.metricsDetailPage == .processes) + #expect(descendant(in: hosting, ofType: NSTableView.self) === table) + #expect(table.sortDescriptors == sort) + #expect(processScroll.contentView.bounds.minY > 0) + } + state.metricsDetailPage = .status + await layout(hosting) + #expect(statusScroll.contentView.bounds.minY == 20) + state.metricsDetailPage = .processes state.metricsProcessQuery = "node" await layout(hosting) #expect(table.numberOfRows == 2) - window.setContentSize(CGSize(width: 630, height: 600)) + window.setContentSize(CGSize(width: 700, height: 600)) await layout(hosting) #expect(descendant(in: hosting, ofType: NSTableView.self) === table) #expect(table.numberOfRows == 2)