diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 4308cf6b..192dadb8 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -7295,6 +7295,23 @@ } } }, + "Default" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "預設" + } + } + } + }, "Default Network Interfaces" : { "localizations" : { "zh-Hans" : { @@ -10707,22 +10724,6 @@ } } }, - "Follow Global Settings" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "跟随全局设置" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "跟隨全域設定" - } - } - } - }, "Follow Permissions" : { "extractionState" : "stale", "localizations" : { diff --git a/Frameworks/TermindPresentation/ConnectionJourneySections.swift b/Frameworks/TermindPresentation/ConnectionJourneySections.swift index 91f06e73..d30db951 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneySections.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneySections.swift @@ -35,13 +35,15 @@ public struct ConnectionJourneyHeader: View { } VStack(alignment: .leading, spacing: 3) { - Text(journey.destination?.name ?? String(localized: "SSH Connection")) + Text(title) .font(.title3.weight(.semibold)) .lineLimit(1) + .help(title) Text(detail) .font(.callout.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) + .help(detail) } Spacer(minLength: 6) @@ -63,6 +65,8 @@ public struct ConnectionJourneyHeader: View { .padding(.horizontal, isEmbedded ? 16 : 34) } + private var title: String { journey.destination?.name ?? String(localized: "SSH Connection") } + private var detail: String { let protocolName: String = switch journey.purpose { diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index d8f7bad1..d7282429 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -1,33 +1,20 @@ import SwiftUI import TermindDomain -public struct HostIntegrationSection: View { - private let selection: Selection - - public init(@ViewBuilder selection: () -> Selection) { self.selection = selection() } - - public var body: some View { - Section("Integration") { - VStack(alignment: .leading, spacing: 8) { - Text("Shell Integration") - selection.frame(maxWidth: .infinity) - } - } - } -} - -public struct HostShellIntegrationPicker: View { +public struct HostIntegrationSection: View { @Binding private var mode: HostShellIntegrationMode public init(mode: Binding) { _mode = mode } public var body: some View { - Picker("Shell Integration", selection: $mode) { - Text("Enable").tag(HostShellIntegrationMode.enabled) - Text("Follow Global Settings").tag(HostShellIntegrationMode.followGlobalSettings) - Text("Disable").tag(HostShellIntegrationMode.disabled) + Section("Integration") { + Picker("Shell Integration", selection: $mode) { + Text("Default").tag(HostShellIntegrationMode.followGlobalSettings) + Text("Enable").tag(HostShellIntegrationMode.enabled) + Text("Disable").tag(HostShellIntegrationMode.disabled) + } + .pickerStyle(.menu) + .menuOrder(.fixed) } - .labelsHidden() - .pickerStyle(.segmented) } } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index c0ba203a..e16b0053 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -87,14 +87,19 @@ the latest record counts as unchanged. Snippets, Known Hosts, Welcome and editor routes. Settings data is app-wide; window-local Settings navigation remembers and restores the replaced destination. - Desktop detail minimum is 360 points; Assistant reading maximum is independently 840. Opening - Navigator grows the window as needed. Vault reserves a 280-point list and 360–400-point editor, - overlaying below 641 points; covered lists retain their last visible size. Editor/space transitions - take 0.22 seconds, preserve content size and respect Reduce Motion. Metrics below 700 points uses + Navigator grows the window as needed. Vault reserves a 280-point list, one-point divider and fixed + 320-point editor (including the embedded forwarding Journey), overlaying below 601 content points; + the full-page editor fills available width and covered lists retain their last visible size. Editor/space + transitions take 0.22 seconds, preserve content size and respect Reduce Motion. Metrics below 700 points uses a titlebar Status/Processes picker with hidden title; wider layouts show both panels/title. Covered panels retain size, table identity, sorting and scroll. Selection/width are window-local; changing server selects Status, while focusing search or narrowing during search reveals Processes. Compact Pause/Resume goes in More; macOS 15 search uses a toolbar popover. Resizing changes layout, never routes, editor identity, Workspace composition or monitoring ownership. + Vault grouped forms retain native horizontal insets without expanding their scroll content; only + vertical scrolling is allowed. The embedded Journey retains 16-point horizontal insets and a horizontal + route scroller. Host integration uses a native labeled menu on both platforms, fitting narrow editors + without widening them or clipping their footers. - `AssistantLibraryModel` owns standalone conversations, never Workspace panes. Workspaces own SSH/SFTP/serial panes and session-scoped conversations; sessions own live transports and terminal/browser state. Assistant ordering/pinning is metadata-only during runs, approvals or @@ -251,9 +256,10 @@ interactive input capture, retaining bracketed paste/broadcast. Remote mouse cap keep Ghostty handling; mouse reports stay outside broadcast capture. Empty paste arms no deferred capture; bracketed-paste classification spans chunks through its terminator or existing short-lived ticket expiry. -Host Shell Integration is Vault-synced Follow Global Settings/Enable/Disable; V22 adds a column while -freezing V1–V21 Hosts and defaults legacy data to Follow Global. Both explicit-save editors place it after -Group (desktop Group & Tags), with Follow Global centered between Enable/Disable. Each SSH attempt resolves +Host Shell Integration is Vault-synced Default/Enable/Disable; V22 adds a column while +freezing V1–V21 Hosts and defaults legacy data to follow global settings. Both explicit-save editors place it after +Group (desktop Group & Tags), with a native menu ordered Default, Enable, Disable. Default follows the global +preference without changing persisted values. Each SSH attempt resolves destination override/global preference once before authentication for bootstrap, History, Command Generator, system info and directory actions. Live edits never inject scripts/reconnect/change that attempt; new/manual/ automatic reconnects resolve again. SFTP/forwarding route identity is unchanged. diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 69837e8f..c97ac376 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -90,7 +90,7 @@ struct MobileHostEditor: View { ForEach(library.groups) { Text($0.name).tag(Optional($0.id)) } } } - HostIntegrationSection { MobileHostIntegrationPicker(mode: $draft.shellIntegrationMode) } + HostIntegrationSection(mode: $draft.shellIntegrationMode) } ) } diff --git a/TFI/Platform/MobileHostIntegrationPicker.swift b/TFI/Platform/MobileHostIntegrationPicker.swift deleted file mode 100644 index d10f1f76..00000000 --- a/TFI/Platform/MobileHostIntegrationPicker.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI -import TermindApplication -import TermindDomain -import UIKit - -/// Content-sized segments keep the longer follow-global label readable on phones. -struct MobileHostIntegrationPicker: UIViewRepresentable { - private static let modes: [HostShellIntegrationMode] = [.enabled, .followGlobalSettings, .disabled] - @Binding var mode: HostShellIntegrationMode - @Environment(\.locale) private var locale - - func makeUIView(context: Context) -> UISegmentedControl { - let control = UISegmentedControl(items: ["", "", ""]) - control.apportionsSegmentWidthsByContent = true - control.addTarget(context.coordinator, action: #selector(Coordinator.select(_:)), for: .valueChanged) - control.setContentHuggingPriority(.defaultLow, for: .horizontal) - return control - } - - func updateUIView(_ control: UISegmentedControl, context: Context) { - context.coordinator.mode = $mode - let labels = [ - AppLocalization.string("Enable", locale: locale), - AppLocalization.string("Follow Global Settings", locale: locale), - AppLocalization.string("Disable", locale: locale), - ] - for (index, label) in labels.enumerated() { control.setTitle(label, forSegmentAt: index) } - control.accessibilityLabel = AppLocalization.string("Shell Integration", locale: locale) - control.selectedSegmentIndex = Self.modes.firstIndex(of: mode) ?? 1 - control.isEnabled = context.environment.isEnabled - } - - func sizeThatFits(_ proposal: ProposedViewSize, uiView: UISegmentedControl, context: Context) -> CGSize? { - CGSize(width: proposal.width ?? uiView.intrinsicContentSize.width, height: uiView.intrinsicContentSize.height) - } - - func makeCoordinator() -> Coordinator { Coordinator(mode: $mode) } - - final class Coordinator: NSObject { - var mode: Binding - - init(mode: Binding) { self.mode = mode } - - @objc func select(_ control: UISegmentedControl) { - guard MobileHostIntegrationPicker.modes.indices.contains(control.selectedSegmentIndex) else { return } - mode.wrappedValue = MobileHostIntegrationPicker.modes[control.selectedSegmentIndex] - } - } -} diff --git a/TFITests/MobileHostIntegrationRenderingTests.swift b/TFITests/MobileHostIntegrationRenderingTests.swift index 720f2680..d2f18344 100644 --- a/TFITests/MobileHostIntegrationRenderingTests.swift +++ b/TFITests/MobileHostIntegrationRenderingTests.swift @@ -15,9 +15,7 @@ struct MobileHostIntegrationRenderingTests { let controller = UIHostingController( rootView: Form { Section("Group") { Text("Production") } - HostIntegrationSection { - MobileHostIntegrationPicker(mode: Binding(get: { draft.mode }, set: { draft.mode = $0 })) - } + HostIntegrationSection(mode: Binding(get: { draft.mode }, set: { draft.mode = $0 })) } .environment(\.locale, Locale(identifier: language))) let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) @@ -34,22 +32,32 @@ struct MobileHostIntegrationRenderingTests { } try await Task.sleep(for: .milliseconds(500)) controller.view.layoutIfNeeded() - let picker = try #require(descendants(controller.view).compactMap { $0 as? UISegmentedControl }.first) - #expect(picker.numberOfSegments == 3) - #expect(picker.apportionsSegmentWidthsByContent) - #expect(picker.selectedSegmentIndex == 1) - #expect( - picker.titleForSegment(at: 1) - == AppLocalization.string("Follow Global Settings", locale: Locale(identifier: language))) - picker.selectedSegmentIndex = 0 - picker.sendActions(for: .valueChanged) + #expect(descendants(controller.view).compactMap { $0 as? UISegmentedControl }.isEmpty) + let picker = try #require( + descendants(controller.view).compactMap { $0 as? UIButton }.first { $0.showsMenuAsPrimaryAction }) + let labels = ["Default", "Enable", "Disable"].map { + AppLocalization.string($0, locale: Locale(identifier: language)) + } + let actions = try await menuActions(in: picker) + #expect(actions.map(\.title) == labels) + #expect(actions.map(\.state) == [.on, .off, .off]) + picker.sendAction(actions[1]) #expect(draft.mode == .enabled) - picker.selectedSegmentIndex = 2 - picker.sendActions(for: .valueChanged) + controller.view.frame.size.width = 600 + controller.view.layoutIfNeeded() + let enabledActions = try await menuActions(in: picker) + #expect(enabledActions.map(\.title) == labels) + #expect(enabledActions.map(\.state) == [.off, .on, .off]) + picker.sendAction(enabledActions[2]) #expect(draft.mode == .disabled) - picker.selectedSegmentIndex = 1 - picker.sendActions(for: .valueChanged) + controller.view.frame.size.width = 375 + controller.view.layoutIfNeeded() + let disabledActions = try await menuActions(in: picker) + #expect(disabledActions.map(\.title) == labels) + #expect(disabledActions.map(\.state) == [.off, .off, .on]) + picker.sendAction(disabledActions[0]) #expect(draft.mode == .followGlobalSettings) + try await Task.sleep(for: .milliseconds(300)) let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in #expect(window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)) } @@ -63,6 +71,29 @@ struct MobileHostIntegrationRenderingTests { private func descendants(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(descendants) } + + private func menuActions(in button: UIButton) async throws -> [UIAction] { + button.performPrimaryAction() + try await Task.sleep(for: .milliseconds(500)) + let interaction = try #require(button.contextMenuInteraction) + var actions: [UIAction] = [] + interaction.updateVisibleMenu { menu in + actions = self.menuActions(menu) + return menu + } + interaction.dismissMenu() + try await Task.sleep(for: .milliseconds(300)) + try #require(actions.count == 3) + return actions + } + + private func menuActions(_ menu: UIMenu) -> [UIAction] { + menu.children.flatMap { element -> [UIAction] in + if let action = element as? UIAction { return [action] } + if let submenu = element as? UIMenu { return menuActions(submenu) } + return [] + } + } } @Observable @MainActor private final class HostIntegrationDraft { diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index d85c812a..1e2f2edd 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -274,13 +274,11 @@ nonisolated enum Metrics { enum VaultContent { static let min: CGFloat = 280 static let ideal: CGFloat = 680 - static let splitMinWidth: CGFloat = min + 1 + Metrics.VaultDetail.min + static let splitMinWidth: CGFloat = min + 1 + Metrics.VaultDetail.width } enum VaultDetail { - static let min: CGFloat = 360 - static let ideal: CGFloat = 400 - static let max: CGFloat = 720 + static let width: CGFloat = 320 } enum Window { diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index f120be13..105fee14 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -167,7 +167,7 @@ struct HostEditorView: View { } } - HostIntegrationSection { HostShellIntegrationPicker(mode: $draft.shellIntegrationMode) } + HostIntegrationSection(mode: $draft.shellIntegrationMode) if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Section("SSH Command") { diff --git a/TFM/Features/Vault/VaultWorkspaceView.swift b/TFM/Features/Vault/VaultWorkspaceView.swift index 3416ecb8..b8b8d2ac 100644 --- a/TFM/Features/Vault/VaultWorkspaceView.swift +++ b/TFM/Features/Vault/VaultWorkspaceView.swift @@ -80,8 +80,7 @@ struct VaultWorkspaceLayout: Layout { let progress = min(1, max(0, expansion)) // Keep the editor at its open width even while it leaves. Only its // position and the list's reserved space animate, never the form's width. - let detailWidth = - split ? min(Metrics.VaultDetail.ideal, bounds.width - Metrics.VaultContent.min - 1) : bounds.width + let detailWidth = split ? Metrics.VaultDetail.width : bounds.width let listWidth = split ? bounds.width - (detailWidth + 1) * progress : bounds.width let listIsVisible = !showsDetail || split let listSize = listIsVisible ? CGSize(width: listWidth, height: bounds.height) : cache.listSize ?? bounds.size diff --git a/TFM/UI/Components/ChromeComponents.swift b/TFM/UI/Components/ChromeComponents.swift index 8ce1419f..25280741 100644 --- a/TFM/UI/Components/ChromeComponents.swift +++ b/TFM/UI/Components/ChromeComponents.swift @@ -141,7 +141,10 @@ struct VaultDetailScaffold: View { Label(title, systemImage: symbol) .font(.headline) - Spacer() + .lineLimit(1) + .truncationMode(.tail) + .help(title) + Spacer(minLength: 0) } .padding(.horizontal, Metrics.Space.l) .padding(.vertical, Metrics.Space.m) diff --git a/TFMTests/HostIntegrationRenderingTests.swift b/TFMTests/HostIntegrationRenderingTests.swift index 75db98ec..850d401e 100644 --- a/TFMTests/HostIntegrationRenderingTests.swift +++ b/TFMTests/HostIntegrationRenderingTests.swift @@ -8,22 +8,22 @@ import Testing @Suite(.serialized) @MainActor struct HostIntegrationRenderingTests { - @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) - func hostEditorShowsIntegrationBelowGroupingAtNarrowWidth(language: String) async throws { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"], [320.0, 360.0, 480.0]) + func hostEditorShowsIntegrationBelowGroupingAtNarrowWidth(language: String, width: CGFloat) async throws { let host = TermindDomain.Host(name: "Production", host: "server.invalid") let app = AppModel(hosts: HostLibraryModel(hosts: [host])) let navigation = WindowModel() let controller = NSHostingController( rootView: HostEditorView(hostID: host.id).environment(app).environment(navigation) .environment(\.locale, Locale(identifier: language)) - .frame(width: 360, height: 1400)) + .frame(width: width, height: 1400)) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 360, height: 1400), + contentRect: NSRect(x: 0, y: 0, width: width, height: 1400), styleMask: [.borderless], backing: .buffered, defer: false) window.isReleasedWhenClosed = false controller.sizingOptions = [] window.contentViewController = controller - controller.view.frame = NSRect(x: 0, y: 0, width: 360, height: 1400) + controller.view.frame = NSRect(x: 0, y: 0, width: width, height: 1400) window.orderFront(nil) defer { app.shutdownForTermination() @@ -33,18 +33,19 @@ struct HostIntegrationRenderingTests { } try await Task.sleep(for: .milliseconds(500)) controller.view.layoutSubtreeIfNeeded() - let control = try #require(descendants(controller.view).compactMap { $0 as? NSSegmentedControl }.first) - #expect(control.segmentCount == 3) - #expect(control.selectedSegment == 1) - let labels = (0..<3).map { control.label(forSegment: $0) ?? "" } - #expect(labels.allSatisfy { !$0.isEmpty }) - #expect(labels[1] == AppLocalization.string("Follow Global Settings", locale: Locale(identifier: language))) + let defaultLabel = AppLocalization.string("Default", locale: Locale(identifier: language)) + #expect(descendants(controller.view).compactMap { $0 as? NSSegmentedControl }.isEmpty) + let control = try #require( + descendants(controller.view).compactMap { $0 as? NSPopUpButton }.first { $0.title == defaultLabel }) + let frame = control.convert(control.bounds, to: controller.view) + #expect(frame.minX >= 16 && frame.maxX <= width - 16) let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) let data = try #require(bitmap.representation(using: .png, properties: [:])) - Attachment.record(data, named: "host-integration-mac-\(language).png") + Attachment.record(data, named: "host-integration-mac-\(language)-\(Int(width)).png") try data.write( - to: FileManager.default.temporaryDirectory.appending(path: "host-integration-mac-\(language).png")) + to: FileManager.default.temporaryDirectory.appending( + path: "host-integration-mac-\(language)-\(Int(width)).png")) } private func descendants(_ view: NSView) -> [NSView] { diff --git a/TFMTests/PortForwardingRenderingTests.swift b/TFMTests/PortForwardingRenderingTests.swift index 8937f50f..2a389986 100644 --- a/TFMTests/PortForwardingRenderingTests.swift +++ b/TFMTests/PortForwardingRenderingTests.swift @@ -123,7 +123,7 @@ struct PortForwardingRenderingTests { let prompt = try #require(context.authenticationPrompt) navigation.openVaultWork(.forwardingEditor(rules[0].id), on: .portForwarding) var passwordField: NSSecureTextField? - for width in [960.0, 360.0, 640.0, 641.0, 681.0, 960.0] { + for width in [960.0, 360.0, 600.0, 601.0, 640.0, 641.0, 681.0, 960.0] { window.setContentSize(NSSize(width: width, height: 440)) controller.view.frame = NSRect(x: 0, y: 0, width: width, height: 440) try await Task.sleep(for: .milliseconds(300)) @@ -172,7 +172,8 @@ struct PortForwardingRenderingTests { @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) func embeddedJourneyDecisionsAndLogsFitShortNarrowColumns(_ language: String) async throws { - let host = Host(name: "Production Database Gateway with a Long Name", host: "gateway.example.invalid") + let host = Host( + name: "AWS US-West-1 Production Database Gateway with a Long Name", host: "gateway.example.invalid") let jump = Host(name: "Jump Host", host: "jump.example.invalid") let app = AppModel(hosts: HostLibraryModel(hosts: [host, jump])) defer { app.shutdownForTermination() } @@ -208,7 +209,7 @@ struct PortForwardingRenderingTests { journey.showsLogs = true } let controller = NSHostingController( - rootView: VaultDetailScaffold(title: "Port Forwarding", symbol: "arrow.left.arrow.right", close: {}) { + rootView: VaultDetailScaffold(title: "\(host.name)", symbol: "arrow.left.arrow.right", close: {}) { ConnectionJourneyView( journey: journey, hostKey: hostKey, submitAuthentication: { _, _, _, _, _, _ in actions += 1 }, @@ -224,7 +225,7 @@ struct PortForwardingRenderingTests { .environment(app) .environment(\.locale, Locale(identifier: language))) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 440), + contentRect: NSRect(x: 0, y: 0, width: 320, height: 440), styleMask: [.titled, .resizable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false controller.sizingOptions = [] @@ -238,7 +239,7 @@ struct PortForwardingRenderingTests { window.close() } var fields: [NSTextField]? - for width in [400.0, 360.0, 400.0] { + for width in [320.0, 360.0, 320.0] { window.setContentSize(NSSize(width: width, height: 440)) controller.view.frame = NSRect(x: 0, y: 0, width: width, height: 440) try await Task.sleep(for: .milliseconds(100)) diff --git a/TFMTests/ResponsivePageLayoutTests.swift b/TFMTests/ResponsivePageLayoutTests.swift index 5097c79a..c4881b33 100644 --- a/TFMTests/ResponsivePageLayoutTests.swift +++ b/TFMTests/ResponsivePageLayoutTests.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import TermindApplication +import TermindDomain import Testing @testable import Termind @@ -8,6 +9,65 @@ import Testing @MainActor @Suite(.serialized) struct ResponsivePageLayoutTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func vaultEditorsKeepFieldsInsideNarrowColumns(_ language: String) async throws { + let host = TermindDomain.Host(name: "Production Database Gateway with a Long Name", host: "server.invalid") + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + let navigation = WindowModel() + defer { app.shutdownForTermination() } + let editors: [(String, VaultWorkRoute)] = [ + ("host", .hostEditor(host.id)), + ("group", .hostGroupEditor(groupID: nil, parentID: nil)), + ("identity", .identityEditor(nil)), + ("known-host", .knownHostEditor(nil)), + ("forwarding", .forwardingEditor(nil)), + ("snippet", .snippetEditor(snippetID: nil, packageID: nil)), + ("package", .snippetPackageEditor(packageID: nil, parentID: nil)), + ("generate-key", .generateKey), + ] + for (name, route) in editors { + let controller = NSHostingController( + rootView: VaultWorkDetailView(route: route).environment(app).environment(navigation) + .environment(\.locale, Locale(identifier: language))) + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 320, height: 640), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + controller.sizingOptions = [] + window.contentViewController = controller + window.setContentSize(CGSize(width: 320, height: 640)) + controller.view.frame = CGRect(x: 0, y: 0, width: 320, height: 640) + window.orderFront(nil) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + await layout(controller.view) + #expect(abs(controller.view.bounds.width - 320) <= 1) + let scroll = try #require(descendant(in: controller.view, ofType: NSScrollView.self)) + let document = try #require(scroll.documentView) + #expect(document.frame.width <= scroll.contentView.bounds.width + 1, "\(name) overflows horizontally") + #expect(scroll.contentInsets.left >= 0 && scroll.contentInsets.right >= 0) + #expect(!scroll.hasHorizontalScroller) + let fields = descendants(in: controller.view).compactMap { $0 as? NSTextField } + .filter { $0.isEditable && !$0.isHiddenOrHasHiddenAncestor } + #expect(!fields.isEmpty) + for field in fields { + let frame = field.convert(field.bounds, to: controller.view) + #expect(frame.minX >= 0 && frame.maxX <= 321, "\(name): \(frame)") + } + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let filename = "macos-vault-\(name)-\(language)-320.png" + Attachment.record(data, named: filename) + let directory = FileManager.default.temporaryDirectory.appending(path: "termind-vault-ui") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appending(path: filename)) + } + } + @Test func metricsPagesAndResizeKeepTheProcessTableAndBothScrollPositions() async throws { let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english) @@ -97,10 +157,10 @@ struct ResponsivePageLayoutTests { let listView = try #require(list.view) let editorView = try #require(editor.view) editorView.stringValue = "Unsaved host name" - #expect(abs(editorView.bounds.width - 400) <= 1) + #expect(abs(editorView.bounds.width - 320) <= 1) let listSize = listView.bounds.size - for width in [360.0, 640.0] { + for width in [360.0, 600.0] { host.frame.size.width = width await layout(host) #expect(editor.view === editorView) @@ -109,17 +169,17 @@ struct ResponsivePageLayoutTests { #expect(listView.bounds.size == listSize) } - host.frame.size.width = 641 + host.frame.size.width = 601 await layout(host) #expect(editorView.stringValue == "Unsaved host name") - #expect(abs(editorView.bounds.width - 360) <= 1) + #expect(abs(editorView.bounds.width - 320) <= 1) #expect(abs(listView.bounds.width - 280) <= 1) - for width in [681.0, 900.0] { + for width in [640.0, 641.0, 681.0, 900.0] { host.frame.size.width = width await layout(host) - #expect(abs(editorView.bounds.width - 400) <= 1) - #expect(abs(listView.bounds.width - (width - 401)) <= 1) + #expect(abs(editorView.bounds.width - 320) <= 1) + #expect(abs(listView.bounds.width - (width - 321)) <= 1) #expect(editorView.stringValue == "Unsaved host name") } @@ -133,7 +193,7 @@ struct ResponsivePageLayoutTests { #expect(editor.creations == 1) } - @Test(arguments: [360.0, 900.0]) + @Test(arguments: [360.0, 600.0, 601.0, 900.0]) func vaultOpeningAndClosingSlideWithoutSqueezingTheEditor(_ width: CGFloat) async throws { let list = Probe() let editor = Probe() @@ -154,8 +214,8 @@ struct ResponsivePageLayoutTests { let frame = editorView.convert(editorView.bounds, to: host) if progress == 0.5 { #expect(frame.minX > width - editorWidth && frame.minX < width) - if width == 900 { - #expect(listView.bounds.width > 499 && listView.bounds.width < 900) + if width >= 601 { + #expect(abs(listView.bounds.width - (width - 321 * progress)) <= 1) } } else if progress == 1 { #expect(abs(frame.maxX - width) <= 1) @@ -181,6 +241,10 @@ struct ResponsivePageLayoutTests { return view.subviews.lazy.compactMap { descendant(in: $0, ofType: type) }.first } + private func descendants(in view: NSView) -> [NSView] { + [view] + view.subviews.flatMap { descendants(in: $0) } + } + private final class Probe { var view: NSTextField? var creations = 0