diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index f9de0a99..e9bc3c7c 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -210,6 +210,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private var pendingResetSpaceEstimate: String? private var resetConfirmationPrompt: ResetConfirmationPrompt? private weak var startMenuScrollView: NSScrollView? + private var preferredContentHeight: CGFloat = 832 private(set) var portForwardingEditor: PortForwardingEditor? private(set) var resourceEditor: VMResourceEditor? private weak var immersiveCaption: NSTextField? @@ -333,15 +334,22 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func prepareForPresentation(visibleFrame: NSRect?) { + let scrollOffset = startMenuScrollView?.contentView.bounds.origin.y ?? 0 render() if let visibleFrame { - // The resources row adds one 72pt row to the menu that previously - // fit at 760. At 690 the launch button cleared the bottom edge by - // 15pt, which any difference in system font metrics turned into a - // button clipped off the window; on displays shorter than the - // window the content scrolls rather than clips. - let availableHeight = max(480, visibleFrame.height - 32) - window.setContentSize(NSSize(width: 600, height: min(832, availableHeight))) + let availableContent = window.contentRect( + forFrameRect: visibleFrame.insetBy(dx: 0, dy: 16) + ) + window.setContentSize(NSSize( + width: 600, + height: min(preferredContentHeight, max(1, availableContent.height)) + )) + content.layoutSubtreeIfNeeded() + if let scrollView = startMenuScrollView, let document = scrollView.documentView { + let maximumOffset = max(0, document.frame.height - scrollView.contentView.bounds.height) + scrollView.contentView.scroll(to: NSPoint(x: 0, y: min(scrollOffset, maximumOffset))) + scrollView.reflectScrolledClipView(scrollView.contentView) + } } } @@ -711,12 +719,6 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { reset.heightAnchor.constraint(equalToConstant: 30).isActive = true reset.widthAnchor.constraint(greaterThanOrEqualToConstant: 154).isActive = true - let resetViews: [NSView] = [reset] - let resetSection = NSStackView(views: resetViews) - resetSection.orientation = .vertical - resetSection.alignment = .centerX - resetSection.spacing = 4 - let launchButtonTitle = launchInProgress ? "Launching Omarchy…" : "Launch Omarchy" let launchButton = OmarchyActionButton( title: launchButtonTitle, @@ -769,12 +771,17 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { footer.allowsEditingTextAttributes = true footer.translatesAutoresizingMaskIntoConstraints = false - let footerContainer = NSView() - footerContainer.addSubview(footer) + footer.identifier = NSUserInterfaceItemIdentifier("start-menu-attribution") + let secondaryActions = NSView() + secondaryActions.addSubview(reset) + secondaryActions.addSubview(footer) NSLayoutConstraint.activate([ - footer.centerXAnchor.constraint(equalTo: footerContainer.centerXAnchor), - footer.topAnchor.constraint(equalTo: footerContainer.topAnchor), - footer.bottomAnchor.constraint(equalTo: footerContainer.bottomAnchor), + reset.centerXAnchor.constraint(equalTo: secondaryActions.centerXAnchor), + reset.topAnchor.constraint(equalTo: secondaryActions.topAnchor), + reset.bottomAnchor.constraint(equalTo: secondaryActions.bottomAnchor), + footer.trailingAnchor.constraint(equalTo: secondaryActions.trailingAnchor), + footer.centerYAnchor.constraint(equalTo: reset.centerYAnchor), + footer.leadingAnchor.constraint(greaterThanOrEqualTo: reset.trailingAnchor, constant: 12), ]) let stack = NSStackView(views: [ @@ -783,9 +790,6 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { permissionCard, integrationHeading, integrationCard, - resetSection, - launchButton, - footerContainer, ]) stack.orientation = .vertical stack.alignment = .leading @@ -794,11 +798,15 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { stack.setCustomSpacing(6, after: permissionHeading) stack.setCustomSpacing(16, after: permissionCard) stack.setCustomSpacing(6, after: integrationHeading) - stack.setCustomSpacing(12, after: integrationCard) - stack.setCustomSpacing(12, after: resetSection) - stack.setCustomSpacing(8, after: launchButton) stack.translatesAutoresizingMaskIntoConstraints = false + let actions = NSStackView(views: [launchButton, secondaryActions]) + actions.orientation = .vertical + actions.alignment = .leading + actions.spacing = 12 + actions.identifier = NSUserInterfaceItemIdentifier("start-menu-actions") + actions.translatesAutoresizingMaskIntoConstraints = false + let document = StartMenuDocumentView() document.translatesAutoresizingMaskIntoConstraints = false document.addSubview(stack) @@ -813,28 +821,32 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { scrollView.identifier = NSUserInterfaceItemIdentifier("start-menu-scroll") scrollView.translatesAutoresizingMaskIntoConstraints = false content.addSubview(scrollView) + content.addSubview(actions) startMenuScrollView = scrollView NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor), scrollView.topAnchor.constraint(equalTo: content.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: content.bottomAnchor), + scrollView.bottomAnchor.constraint(equalTo: actions.topAnchor, constant: -12), + actions.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 42), + actions.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -42), + actions.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20), document.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), document.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.contentView.heightAnchor), stack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 42), stack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -42), stack.topAnchor.constraint(equalTo: document.topAnchor, constant: 26), - stack.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -20), + stack.bottomAnchor.constraint(equalTo: document.bottomAnchor), permissionCard.widthAnchor.constraint(equalTo: stack.widthAnchor), integrationCard.widthAnchor.constraint(equalTo: stack.widthAnchor), - resetSection.widthAnchor.constraint(equalTo: stack.widthAnchor), - launchButton.widthAnchor.constraint(equalTo: stack.widthAnchor), - footerContainer.widthAnchor.constraint(equalTo: stack.widthAnchor), + secondaryActions.widthAnchor.constraint(equalTo: actions.widthAnchor), + launchButton.widthAnchor.constraint(equalTo: actions.widthAnchor), ]) content.layoutSubtreeIfNeeded() document.layoutSubtreeIfNeeded() + preferredContentHeight = ceil(stack.fittingSize.height + actions.fittingSize.height + 58) let maximumOffset = max( 0, document.frame.height - scrollView.contentView.bounds.height diff --git a/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift b/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift index cd14b443..95b76b34 100644 --- a/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift @@ -89,13 +89,63 @@ struct StartMenuWindowWidthTests { try expectDetailsFit(["permission-detail-externaldrive"], in: menu) } + @Test("Launch stays visible without scrolling on short and tall displays") + func actionsStayVisible() throws { + _ = NSApplication.shared + for screenHeight: CGFloat in [500, 700, 900, 1400] { + let menu = makeMenu(storageState: { StorageLocationMenuState( + containerPath: nil, stateRoot: nil, displayPath: "Default location", + volumeName: nil, isDefault: true, isExternal: false, + problem: nil, warning: nil, isEnvironmentOverride: false + ) }, permissionsGranted: false) + defer { menu.dismiss() } + let screen = NSRect(x: 0, y: 0, width: 1440, height: screenHeight) + menu.prepareForPresentation(visibleFrame: screen) + let content = try #require(menu.window.contentView) + content.layoutSubtreeIfNeeded() + let scroll = try #require(descendant(withIdentifier: "start-menu-scroll", in: content) as? NSScrollView) + let launch = try #require(descendant(withIdentifier: "launch-button", in: content) as? NSButton) + let reset = try #require(descendant(withIdentifier: "reset-button", in: content)) + let launchFrame = launch.convert(launch.bounds, to: content) + let resetFrame = reset.convert(reset.bounds, to: content) + let attribution = try #require(descendant(withIdentifier: "start-menu-attribution", in: content)) + let attributionFrame = try #require(attribution.superview).convert( + attribution.alignmentRect(forFrame: attribution.frame), to: content + ) + #expect(abs(resetFrame.midX - launchFrame.midX) < 0.5) + #expect(abs(attributionFrame.maxX - launchFrame.maxX) < 0.5) + // AppKit can round an odd-height label to a half-point center offset. + #expect(abs(attributionFrame.midY - resetFrame.midY) <= 0.5) + #expect(attributionFrame.minX > resetFrame.maxX) + #expect(content.bounds.contains(launchFrame)) + #expect(content.bounds.contains(resetFrame)) + #expect(launchFrame.minY > resetFrame.maxY) + #expect(descendant(withIdentifier: "launch-button", in: scroll) == nil) + #expect(launch.keyEquivalent == "\r") + #expect(menu.window.frame.height <= screen.height - 32 + 0.5) + let document = try #require(scroll.documentView) + #expect(scroll.contentView.bounds.origin.y == 0) + if screenHeight == 1400 { + #expect(document.frame.height <= scroll.contentView.bounds.height + 0.5) + #expect(content.bounds.height > 832) + } else if screenHeight == 500 { + #expect(document.frame.height > scroll.contentView.bounds.height) + } + let originalLaunchFrame = launchFrame + scroll.contentView.scroll(to: NSPoint(x: 0, y: max(0, document.frame.height - scroll.contentView.bounds.height))) + scroll.reflectScrolledClipView(scroll.contentView) + #expect(launch.convert(launch.bounds, to: content) == originalLaunchFrame) + } + } + private func makeMenu( - storageState: @escaping () -> StorageLocationMenuState + storageState: @escaping () -> StorageLocationMenuState, + permissionsGranted: Bool = true ) -> StartMenuWindow { StartMenuWindow( - accessibilityStatus: { true }, - microphoneStatus: { .authorized }, - cameraStatus: { .authorized }, + accessibilityStatus: { permissionsGranted }, + microphoneStatus: { permissionsGranted ? .authorized : .notDetermined }, + cameraStatus: { permissionsGranted ? .authorized : .notDetermined }, requestAccessibility: {}, requestMicrophone: { completion in completion(true) }, requestCamera: { completion in completion(true) },