Add compact Metrics detail switching
This commit is contained in:
1 parent
0e85a5cec9
commit
e8b85c45da
10 files changed
+447
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UUID> = []
|
||||
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 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<T: NSView>(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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user